Skip to content

fix(seidroid-review): withdraw the reactions on a cancelled run, from a step that cannot post - #100

Merged
bdchatham merged 7 commits into
feat/seidroid-reviewfrom
fix/clear-the-reactions-on-a-cancelled-run
Sep 7, 2026
Merged

fix(seidroid-review): withdraw the reactions on a cancelled run, from a step that cannot post#100
bdchatham merged 7 commits into
feat/seidroid-reviewfrom
fix/clear-the-reactions-on-a-cancelled-run

Conversation

@bdchatham

@bdchatham bdchatham commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

A run cancelled by a newer @seidroid review now clears the reactions it left on its own
trigger comment, from a step that cannot post one. It left the 👀 there for good.

Carries PLT-1166. PLT-1159 comes out ruled out, on the evidence below. Nobody can
do it as written. The permission comment records the reason beside the scope the ticket
asked to drop.

PLT-1166 — the defect

Answer the request took !cancelled(). Two @seidroid review comments in quick
succession put both runs in one concurrency group under cancel-in-progress, so the newer
one cancels the older. By then the older comment already wears the 👀 — the
acknowledgement is the first step of the job. The newer run answers its own comment
id, so nothing ever reads the older one again.

The result: a comment that asked for a review, wears eyes, and never gets an answer. That
is the defect PLT-1144 fixed on the no-verdict path, reached by the one path that fix does
not cover.

What ships

Answer the request keeps !cancelled() — unchanged from the base. The withdrawal is a
new step, last in the job:

- name: Withdraw the reactions on a cancelled run
  if: ${{ inputs.mode == 'review' && cancelled()
    && needs.guard.outputs.comment_id != ''
    && steps.verdict.outputs.posted != 'true' }}

Post the verdict gains id: verdict and a posted output so that last term can read
it, and Answer the request gains id: answer. Nothing else in the workflow changes.

Why not always() on Answer the request

That was the first shape here and it was wrong. A step output persists once its step
completes, so a cancellation landing any time after drive finishes leaves check_path
and verdict_produced populated. Answer the request would read a real conclusion and
post a thumb, while every publisher skips on !cancelled(). A thumb reads as an answer.
That is worse than the stale eyes this PR set out to remove.

Why the new step cannot state an outcome

It contains no POST. The script lists this bot's reactions and deletes the three this
workflow posts. No code path in it adds one. Nothing the step receives can therefore make
it state an outcome. That property holds whatever its inputs are, which is what makes it
structural rather than a matter of what a cancellation happens to look like.

It reads no check file, no verdict_produced and no conclusion.

What the steps.verdict.outputs.posted term is, and why it does not break that

A cancellation can arrive once the verdict is already on the pull request — during thread
resolution, say — and the thumb Answer the request posted answers it correctly.
Withdrawing it there leaves a published review with no reaction on the request that asked
for it, which reads as never answered. That is this step's own defect, one window later.

The posting step's posted output separates the two. It is one boolean about another
step, written from the comment POST's own result. No conclusion is in it. It says whether
an answer already stands, never which answer it would be, so reading it gives the step
nothing to state. Handing it verdict_produced instead would have restored the
conditional reasoning above: that flag is true whenever the driver reached a verdict,
including when nothing published.

Its outcome will not do, and that took a second pass to see. Post the verdict runs
under continue-on-error and tolerates a refused comment POST. Its failure path ends on
a call whose failure it swallows. The step therefore exits 0, and its outcome reads
success whether the verdict landed or not. The first version of this gate read that
outcome. A refused POST followed by a cancellation then kept a thumb standing for a
review nobody can see. That step already tracked the POST's result in a shell variable.
It now writes it as an output.

Anything but a posted verdict withdraws. A value the step cannot read therefore clears.
It does not leave a thumb standing for a verdict that may not be on the pull request.

The cross-run half of the same problem

A re-run replays the trigger comment id. The comment can therefore already carry a thumb
from an earlier run whose verdict is on the pull request. A re-run cancelled before it
answered took that thumb along with its own eyes. The comment then ended bare while the
verdict it asked for still stood.

Answer the request gains id: answer, and the withdrawal reads its outcome to decide
what this run may take:

steps.answer.outcome what it means withdrawn
skipped this run never touched the comment, so a thumb there is an earlier run's eyes only
success this run withdrew the stale thumb and posted its own, and published nothing +1 -1 eyes
failure, cancelled, unreadable the step ran partway and most likely took the earlier thumb already +1 -1 eyes

The structural property is untouched: still no POST, and an outcome is still four words
about another step with no conclusion among them.

One case survives, and the comment states it rather than claiming it away. A run
answers, which withdraws an earlier thumb and posts its own. A cancellation then arrives
before publishing, and the comment ends bare. The answer step already took the earlier
thumb, so nothing at the end of the job can put it back. Knowing it happened would need a
read of the pull request this step deliberately does not make. A cancellation lands during
the driver far more often than in that gap. The step's comment records the limit instead
of asserting the invariant outright.

One tension with the stated acceptance criterion, deliberately. "Given a run cancelled
by a newer request, its trigger comment carries no reaction from this bot" now fails on
one path. A cancelled re-run leaves an earlier run's thumb. The criterion's intent holds.
The
comment does not wear 👀 with no answer coming, because the answer is on the pull request.
Satisfying the literal wording would restore the defect above. I flag it rather than read
the criterion loosely.

Why last in the job, and what holds it there

The runner evaluates a step's condition when it reaches the step. Any step after the
withdrawal is a step during which a cancellation leaves the eyes standing.
The runner
already evaluated the withdrawal and skipped it by then. Placed last it also reads
steps.verdict.outputs.posted after that step has reported.

conditions.py checks the position rather than any one ordering, which covers a step
appended later. Four mutations fail it. Move the withdrawal ahead of Post the verdict,
ahead of the resolve step, or ahead of the no-verdict report. Or append a step after it.
The id-ordering check caught only the first. In the other three Post the verdict still
ran earlier.

Why not the fix as named

Gating the conclusion read inside the script needs the job status in the shell, and GitHub
does not offer it there. cancelled() is readable only in a step or job if.
PipelineTemplateEvaluator.EvaluateStepEnvironment calls
CreateContext(contextData, expressionFunctions) with no expressionState, where
EvaluateStepIf passes step.ExecutionContext.ToExpressionState(). And
CancelledFunction.EvaluateCore reads templateContext.State[nameof(IExecutionContext)]
and ArgUtil.NotNulls it. StepsRunner turns that throw into
CompleteStep(step, TaskResult.Failed), so env: CANCELLED: ${{ cancelled() }} fails the
step on every run, before the runner evaluates its condition. actionlint refuses it too:
calling function "cancelled" is not allowed here. "cancelled" is only available in "jobs.<job_id>.if", "jobs.<job_id>.steps.if". The same holds for run:.

The cancellation shapes, and which this covers

when the cancellation lands what runs outcome covered
while queued, job never starts nothing no eyes were ever posted n/a
before drive completes withdrawal all three withdrawn, no thumb yes
after drive completes withdrawal it cannot read the populated outputs yes
while Answer the request runs its !cancelled() re-test fires, the runner kills it, then the withdrawal the withdrawal takes whatever it left yes
after the thumb, before the verdict published answer, then withdrawal the withdrawal takes the thumb: it would stand for nothing yes
after the verdict published answer only thumb survives beside the published verdict yes
the verdict POST refused, then cancelled answer, then withdrawal thumb withdrawn: its outcome still reads success yes
during the withdrawal step withdrawal its own condition is cancelled(), so the re-test keeps it alive yes
once the runner reached every step answer only a thumb this run earned stays correct
a re-run, cancelled before it answered withdrawal an earlier run's thumb stays, its eyes go yes
a re-run, answered then cancelled before publishing answer, then withdrawal bare comment, earlier verdict stands no
runner process shutdown (RunnerShutdownToken) nothing StepsRunner skips condition evaluation outright no

Two rows are gaps. In the answered-then-cancelled row the answer step has already taken
the thumb, so no later step can restore it. A hard kill of the runner leaves the eyes on
the comment, and nothing inside a workflow closes that.

The reaction table

Each case declares two job states: the one the runner reached Answer the request in, and
the one it reached the withdrawal step in. success>cancelled is a cancellation that
arrived after the answer, so the answer step posts its own thumb and the fixture places
nothing by hand.

case states verdict outcome ran left on the comment
success success>success success answer bot:+1
failure success>success success answer bot:-1
no verdict success>success skipped answer none
neutral success>success skipped answer none
cancelled after drive, outputs populated cancelled>cancelled skipped withdraw none
cancelled before drive finished cancelled>cancelled skipped withdraw none
cancelled mid-publish success>cancelled cancelled answer+withdraw none
the verdict failed to post success>cancelled failure answer+withdraw none
the outcome went unreported success>cancelled empty answer+withdraw none
cancelled after the verdict published success>cancelled success answer bot:+1
the same, beside a human's success>cancelled success answer brandon:-1, bot:+1
success, human +1 -1 eyes success>success success answer human ×3, bot:+1
failure, human ×3 success>success success answer human ×3, bot:-1
no verdict, human ×3 success>success skipped answer human ×3
cancelled, human ×3 cancelled>cancelled skipped withdraw human ×3
stale bot:-1 + human:+1 success>success success answer human:+1, bot:+1
the same, cancelled cancelled>cancelled skipped withdraw human:+1
stale bot:+1, no verdict success>success skipped answer none
bot:rocket from another workflow success>success success answer human:+1, bot:+1, bot:rocket
the same, cancelled cancelled>cancelled skipped withdraw human:+1, bot:rocket
the list call refused success>success success answer human:+1, bot:+1, bot:eyes + warning
a delete refused success>success success answer bot:+1, bot:eyes + warning
the add refused success>success success answer none + warning
the list refused, cancelled cancelled>cancelled skipped withdraw bot:eyes + warning
a delete refused, cancelled cancelled>cancelled skipped withdraw bot:eyes + warning
the acknowledgement refused success>success success answer bot:+1

A human's reaction survives every path. A bot:rocket some other workflow left survives
too, because each step deletes only the contents this workflow posts. And a thumb that
answers a published verdict survives a later cancellation.

PLT-1159 — ruled out, with the evidence

The ticket's premise is that ai-review.yml reaches the same reactions through GraphQL
addReaction under pull-requests: write. It does not, and the workflow file does not
decide the question.

1. ai-review.yml does call both mutations, and its permissions: blocks do lack
issues.
preflight is contents: read + pull-requests: write and calls
addReaction(content: EYES); complete_review_reaction is pull-requests: write alone
and calls addReaction(THUMBS_UP) then removeReaction(EYES).

2. But neither call uses GITHUB_TOKEN. Both steps pass
github-token: ${{ steps.app-token.outputs.token || github.token }}, and in production
the App token wins. I checked live comments. Every reaction on an @seidroid review
trigger in sei-chain belongs to seidroid[bot], not to github-actions[bot]:

comment 5536974191  +1 by seidroid[bot]   sei-chain#4088
comment 5544795940  +1 by seidroid[bot]   sei-chain#4101
comment 5531689281  +1 by seidroid[bot]   sei-chain#4095
comment 5493838638  +1 by seidroid[bot]   sei-chain#4063

The workflow's permissions: block does not bound an App installation token. Its own
installation grant governs, and that App holds Issues: write — ai-assistant.yml posts
POST /repos/{o}/{r}/issues/comments/{id}/reactions with the same token. ai-review is
therefore no evidence at all about what pull-requests: write alone can do. It is the
same class of wrong premise as the enable-cursor: false one.

3. GitHub documents no permission for any GraphQL mutation. Not a gap in my reading —
checked at the data source. In github/docs, src/graphql/data/fpt/schema-reactions.json
gives addReaction and removeReaction the keys name, id, href, description, isDeprecated, inputFields, returnFields, category and no permission field. The public SDL
(docs.github.com/public/fpt/schema.docs.graphql) carries only
@docsCategory(name: "reactions"). The GraphQL guide's whole statement on the subject is
that the API returns an error naming the permission it wanted. One route therefore remains
to the requirement: make the call.

4. GitHub documents what REST requires, and the alias stops at the reaction.
github/docs, src/github-apps/data/fpt-2026-03-10/server-to-server-permissions.json:

endpoint permission
GET/PATCH/DELETE /repos/{o}/{r}/issues/comments/{id} listed under both issues and pull_requests
POST /repos/{o}/{r}/issues/comments/{id}/reactions issues: write only
DELETE /repos/{o}/{r}/issues/comments/{id}/reactions/{rid} issues: write only
POST /repos/{o}/{r}/pulls/comments/{id}/reactions pull_requests: write (a review comment — a different resource)

That file expresses "either permission" by listing an endpoint twice. A single listing on
the reactions endpoints is therefore a distinction, not an omission. It confirms the claim
the guard job already makes in prose.

Verdict. The premise is void and the documentation says nothing. Nothing here can mint
a fine-grained token scoped to pull-requests to test it. Shipping the drop blind would
regress the defect this PR fixes. continue-on-error and a ::warning:: swallow a 403 on
the reaction, so the eyes would stay on every comment and no run would fail. Ruled out.

Two things worth keeping for whoever re-files it

removeReaction beats the REST loop, whatever the scope turns out to be. The ticket
assumed it takes a reaction node id. It does not. RemoveReactionInput is
{content: ReactionContent!, subjectId: ID!}, and the subject is the comment:
IssueComment sits in its @possibleTypes. It takes no actor input, so it can only ever
remove the viewer's own reaction. That makes the human-scoping property structural rather
than a select(.user.login == $me) filter. It also retires the hardcoded
me="github-actions[bot]" login and the paginated list whose miss leaves eyes behind.

A third shape the ticket does not name looks likelier than either. This workflow
already mints an App token (steps.identity.outputs.token) and already computes
REVIEWER_LOGIN from app-slug. Reacting under that identity needs no caller scope at
all, and it is how production already posts these reactions. Two obstacles stand in the
way. The acknowledgement runs before the mint, on purpose. And the mint is optional, so a
GITHUB_TOKEN fallback keeps the scope required — unless a caller without App credentials
may lose the reaction.

The cost this change carries

The list-and-delete block is now duplicated between Answer the request and the
withdrawal step. me="github-actions[bot]" and the set of contents each step may delete
are two copies, and a reader has to keep them in step by hand. Edit one and not the other
and a reaction stays behind on whichever path lost the edit.

That is the price of the separate step, and it buys the structural property: the
withdrawing step has no POST. Sharing the block would mean one step doing both jobs, which
is the shape that produced this PR's blocker. GitHub Actions offers no way to share a
script between two steps without a checkout, and YAML anchors are not supported.

Both harnesses cover both copies, so a drift fails rather than ships. The GraphQL
removeReaction above is what would remove the duplication outright. It needs no login
and no listing, so the whole block collapses to one mutation per content.

Every reaction site

Three steps, six calls, all in the review job, all on the ISSUE comments endpoint:

line step call
1036 Acknowledge the trigger POST .../issues/comments/{id}/reactions (eyes)
2494 Answer the request GET .../reactions --paginate
2504 Answer the request DELETE .../reactions/{rid}
2519 Answer the request POST .../reactions (+1 / -1)
3292 Withdraw the reactions on a cancelled run GET .../reactions --paginate
3304 Withdraw the reactions on a cancelled run DELETE .../reactions/{rid}

The withdrawal step has no POST, and that is the fix. guard reacts nowhere.
ai-assistant.yml and ai-review.yml have their own sites; neither is in this workflow.

Verification

Committed, not kept locally. An uncommitted harness is how the first blocker survived
a reading and seven mutations. Two additions to test/seidroid-review/, wired into
workflow-test-self.yml as their own job so the placement check keeps its name.

reactions.sh — 62 assertions over 32 cases. It runs the reaction steps under bash,
extracted from the workflow on every run. No case names the step it runs.
conditions.py --select names it, from the job state and the posting step's outcome, so
the two layers cannot drift. The gh stub keeps the reaction list a comment carries and
serves it through the step's own --jq. It honours idempotence per (user, content). It
can refuse the list, a delete or the add. The acknowledgement's calls go to a separate
log, so every count belongs to the step under test. The stub reports any call it cannot
serve.

conditions.py — 76 assertions. A step condition decides which reaction step runs in
which job state, and a shell harness cannot see it. The model applies the runner's own
rule: a condition naming none of always/cancelled/failure/success becomes
success() && (...). It treats a term it cannot decide as unknown rather than false. Two
checks read the file rather than a table, so they cover a step added later:

Both walk every job's raw steps list and search the whole step:

  • No step that can run on a cancelled job may reach check_path or verdict_produced.
  • Every steps.<id> a step reads must be a real id on an earlier step.
  • The withdrawal is the last step of the review job.

Keyed off a name they dropped an unnamed step. - uses: ... with no name: is the usual
shape, so the step most likely to arrive later was the one they could not see. The guard
job already carries one. The id check also reached only if, which left the withdrawal's
new env read unchecked.

Each fixture, mutation-tested. A fixture that cannot fail the invariant is not a test
of it:

mutation which case fails
the gate reads steps.verdict.outcome again THUMB GOES on a refused POST, and the matching condition row
the skipped arm takes all three EARLIER THUMB SURVIVES, plus 3 more
id: answer deleted reads steps.answer, which is no step's id
an unnamed always() step reading verdict_produced step 17 runs on a cancelled run and reads verdict_produced
the posted term dropped THUMB SURVIVES + 3 more + the condition row
the same term inverted 16 script cases, 5 condition cases
id: verdict deleted reads steps.verdict, which is no step's id
the withdrawal moved ahead of Post the verdict the position check, and reads steps.verdict, which runs later
the withdrawal moved ahead of the resolve step the position check alone — the id check passes it
the withdrawal moved ahead of the no-verdict report the position check alone
any step appended after the withdrawal the position check
Answer the request back to always() its cancelled row, and the sweep
check_path interpolated inline into run: the sweep — an env-only sweep passes it
the withdrawal step deleted no step named 'Withdraw the reactions on a cancelled run'
the withdrawal not scoped to this bot the human and rocket cancelled cases
the withdrawal ignoring which contents it may take foreign-cancelled loses a rocket
the missing-conclusion arm clearing only the eyes cancelled-with-stale-thumb, no-verdict-with-stale-thumb
the answer's list not scoped to this bot every human case
the empty-reaction guard removed every clear-only case posts a blank reaction
the VERDICT_PRODUCED gate removed no verdict thumbs the requester down
the success arm no longer naming the eyes eyes survive a green review

Only conditions.py catches id: answer deleted. reactions.sh derives that outcome
itself, which is exactly why the id check has to exist.

conditions.py also models steps.verdict.outcome beside posted, derived rather than
passed. It reads success whenever that step reported at all, which is what the runner
sees. A gate that regresses to the outcome therefore fails an assertion instead of
crashing the model.

Five checks ran narrower than their own claim. Mutating the thing each claimed to
cover is what found them.

  • The haystack read env only, so an inline interpolation passed.
  • The model took an outcome as an argument and never checked the id existed.
  • The sweep keyed off a step name, so an unnamed step was invisible.
  • run_case exported each per-case knob without clearing it. An ANSWERED_AS override
    leaked forward and disarmed a later case.
  • The gate read an exit code that cannot express whether the verdict landed.

The first three now read the file. run_case clears the fourth at the top of every case.
The fifth reads an output written from the POST's own result.

actionlint, base vs branch, both rule sets. seidroid-review.yml: 4 × SC2102:info,
unchanged. Whole .github/workflows: 37 findings, identical after line-number
normalisation. workflow-test-self.yml: 0. shellcheck -S warning clean on both new
harness files. The incumbent harness still passes, now 271 assertions after #102.

Base checked three times, and no move trusted. It went to b1b51f8 (#97), then
2f58b80 (#101), then 98c2619 (#102). #97 rewrote 341 lines of this file and added the
harness. #102 rewrote the resolve step and placement, and renamed the harness job. Each
time all three harnesses and actionlint ran again on the new history. None of them carried
a result from before it. I checked each rebase by reading back four things: the step
order, the step ids, the gate, and the withdrawal step's POST count. Not by its exit
status.

The #102 rebase conflicted once, in the harness README, and I resolved it keeping both
sides. workflow-test-self.yml merged cleanly: place-findings keeps #102's renamed
display name Place findings and resolve threads, and the reaction job keeps its own.

One handoff for whoever lands second. #103 replaces the hardcoded issues/comments
with a comment_api output. Its author wrote it against two reaction steps. This
branch leaves three, carrying six hardcoded paths rather than four: one in
Acknowledge the trigger, three in Answer the request, two in the withdrawal step.
Whichever of us rebases second has to reach all three steps.

Not verified

Nothing here ran on a GitHub runner. Five things stay unverified. That a real cancelled
run reaches the last step of the job. That steps.verdict.outcome reads success on a run
cancelled after that step completed. That steps.answer.outcome reads skipped rather
than empty on a run cancelled before that step. That the runner's condition re-test kills
Answer the request mid-flight, as its source says. And every permission claim above. The
cancellation semantics rest on actions/runner source and on actionlint.
conditions.py models the expression engine; it is not the engine.

🤖 Generated with Claude Code

@cursor

cursor Bot commented Sep 7, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes GitHub comment UX and reaction cleanup on cancellation; mistakes could remove valid thumbs or leave stale eyes, but behavior is heavily tested and scoped to bot reactions with explicit gates.

Overview
Fixes stale 👀 on superseded @seidroid review triggers when cancel-in-progress cancels an older run: a new last job step removes this bot’s reactions on cancelled runs and never POSTs a reaction, so it cannot thumb a verdict that never published.

Post the verdict now exposes id: verdict and a posted output (from the comment POST result, not step outcome). Answer the request gets id: answer. The withdrawal runs when cancelled(), review mode, a trigger comment exists, and posted != 'true'; it uses steps.answer.outcome to withdraw only eyes when the answer step never ran (preserve an earlier run’s thumb) vs +1/-1/eyes when this run already answered but published nothing.

Workflow comments document why Answer the request stays !cancelled() (late cancel + populated driver outputs would mis-thumb) and issues: write for reaction REST.

CI: workflow-test-self.yml adds a reactions job running reactions.sh (gh stub, 32 cases) and conditions.py (step if modeling, withdrawal-last invariant, no conclusion reads on cancelled steps).

Reviewed by Cursor Bugbot for commit 0d925ab. 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 fix correctly identifies a real defect (a superseded run abandoning 👀 on its own trigger comment) and always() does compose correctly inside the larger &&, but the reasoning that always() only admits a run with no check file is wrong: a cancellation arriving after drive completes leaves the verdict outputs populated, so this step can post a 👍/👎 while every publishing step skips. Gating the conclusion read on cancelled() preserves the fix without that outcome.

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

Blockers

  • None at the file/PR level.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • The workflow's header block (around line 61) still says the surfacing steps use !cancelled() "rather than success() ... while still skipping a run superseded by a newer trigger." That is now false for one of them. A half-line there pointing at Answer the request as the documented exception would keep the file's own map accurate.

Comment thread .github/workflows/seidroid-review.yml Outdated
# replays the trigger comment id, so a run reaching no verdict still has to clear
# the eyes, and a thumb an earlier attempt left there.
if: ${{ inputs.mode == 'review' && !cancelled()
if: ${{ inputs.mode == 'review' && always()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] always() admits more than the no-check-file case, so this can post a verdict reaction for a review that published nothing.

The justification above ("What always() admits is a run with no check file") assumes cancellation always kills drive before it sets its outputs. Cancellation timing is arbitrary. drive ends at line ~1599; Place findings on the code (1601) is the next step and is the slow one — it can spend a while posting inline comments. A cancellation arriving anywhere from there onward leaves steps.drive.outputs.check_path populated and verdict_produced == 'true', because step outputs persist once the step completes.

In that window every publisher skips on !cancelled()Place findings (1611), Publish the review check run (1733), State the review's position (1854), Post the verdict (2215), Resolve the threads (2530) — while this step now runs and takes the failure/success arm at 2149-2150. The trigger comment ends up wearing 👎 or 👍 with no verdict comment, no check run, and no findings on the diff. A 👎 there is precisely the outcome the VERDICT_PRODUCED gate and the comment at 2119-2124 exist to prevent: a thumb down on a review nobody can read. Worse, if the superseding run then concludes success, the two comments carry contradictory thumbs.

The withdrawal this PR is after does not need the conclusion at all — it needs the missing-conclusion arm. Routing a cancelled run there unconditionally keeps the fix and closes the gap:

        env:
          # A cancelled run publishes nothing, so it has no conclusion to react
          # to however far `drive` got. It still owes the withdrawal.
          CANCELLED: ${{ cancelled() }}

and in the script:

          conclusion=""
          if [ "${CANCELLED:-}" != "true" ] && [ -s "$CHECK" ] && [ "${VERDICT_PRODUCED:-}" = "true" ]; then

That leaves the cancelled run doing exactly what the comment at 2090-2095 describes — clear all three reactions, post no thumb — for every cancellation, not just the early ones.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct, and fixed in df981aa. Answer the request keeps !cancelled(); the withdrawal is now its own step, last in the job, under cancelled(), reading no check file and no conclusion.

The suggested env: CANCELLED: ${{ cancelled() }} cannot ship. cancelled() is readable only in a step or job ifPipelineTemplateEvaluator.EvaluateStepEnvironment builds its context without the expression state CancelledFunction asserts on, so the step fails before its condition runs. actionlint refuses it too: "calling function cancelled is not allowed here."

Last in the job, not beside this step: a condition is evaluated when its step is reached, so a clear placed here is already skipped by the time a cancellation lands on Post the verdict. From the end it also withdraws a thumb this step had just posted.

@bdchatham

Copy link
Copy Markdown
Contributor Author

Non-blocking finding — the header map: fixed in df981aa, naming a different exception than you expected.

Answer the request is no longer the exception. It keeps !cancelled(), so every surfacing step still skips a superseded run and the original sentence stays true. The exception is the new last step, which withdraws the reactions and reads no verdict, so it can state no outcome. The header now says that, and the acknowledgement's own comment now names both withdrawers instead of claiming Answer the request covers every path.

Blocking finding — replied in thread. You were right, and the suggested env: CANCELLED: ${{ cancelled() }} is not available: cancelled() is readable only in a step or job if, so the gate had to move into condition space as a separate step.

Regression cover, since an uncommitted harness is how this survived. test/seidroid-review/reactions.sh runs all three reaction steps against a gh stub — 38 assertions over 22 cases, including a cancelled run with check_path and verdict_produced populated, which is the case my earlier fixtures missed. test/seidroid-review/conditions.py covers what a shell harness cannot see: 33 assertions on which step runs in which job state, with the invariant stated over the file as well as over a table, so no step reading check_path or verdict_produced may run on a cancelled job. Both wired into workflow-test-self.yml as their own job, so the placement check keeps its name. The incumbent 165 assertions still pass.

Nothing ran on a GitHub runner.

@bdchatham bdchatham changed the title fix(seidroid-review): clear the reactions on a cancelled run fix(seidroid-review): withdraw the reactions on a cancelled run, from a step that cannot post Sep 7, 2026

@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 final cancelled()-gated step that withdraws this bot's reactions from the trigger comment of a superseded review run, plus a shell harness and a step-condition model as tests. The mechanism is sound and well tested; two narrower points are worth addressing — the unconditional thumb withdrawal can erase a reaction for a verdict that was actually published, and the new invariant check only inspects step env.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • The PR description documents a different fix than the one implemented: it presents if: ${{ inputs.mode == 'review' && always() && needs.guard.outputs.comment_id != '' }} on Answer the request and argues at length why always() is safe there, while the code instead adds a separate last step gated on cancelled() and its in-file comments argue that always() on Answer the request would be wrong. Worth updating the body so the rationale on record matches the change.
  • The list-and-delete block (hardcoded me="github-actions[bot]", paginated --jq list, delete loop, warning strings) is now duplicated verbatim between Answer the request and the new withdrawal step. The two must stay in sync if the reacting identity or the reaction set ever changes; a shared snippet or a small script under test/-adjacent tooling would remove one copy.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread .github/workflows/seidroid-review.yml Outdated
# step here chooses belongs to whatever put it there.
while read -r content rid; do
[ -n "$rid" ] || continue
case " +1 -1 eyes " in *" $content "*) ;; *) continue ;; esac

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 +1/-1 unconditionally can strip a thumb that correctly answers a published verdict.

Answer the request (line 2338) and Post the verdict (line 2465) both run before Resolve the threads this review closed (line 2746) and Report a review that reached no verdict (line 2960). A cancellation arriving after Post the verdict has already upserted the verdict comment and published the check run — i.e. during thread resolution — still routes here, and this loop deletes the 👍/👎 that Answer the request just posted. The trigger comment then carries no reaction at all even though the review is fully on the pull request, which reads as "never answered" — the same failure mode this PR sets out to fix, in a different window.

The comment at lines 3101-3102 ("a cancellation arriving during or after Answer the request heals rather than leaving a verdict nobody published") only holds when the verdict was in fact not published.

Two ways out, both local: give Post the verdict an id: and pass steps.<id>.outcome in this step's env so the thumb is kept when the verdict landed, or narrow this case to eyes only — a cancellation before Answer the request leaves no thumb from this run anyway, and a thumb from an earlier attempt on a replayed comment id corresponds to a verdict that was published. Either way, reactions.sh would want a case for "cancelled after the verdict was posted".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right, and fixed in 1ba2010. Post the verdict gains id: verdict, and the condition now also names steps.verdict.outcome != 'success'.

The step still cannot state an outcome, and the reason is stronger than an absent input: it contains no POST, so no code path in it adds a reaction whatever it is told. The gained fact is four words about another step — success, failure, cancelled, skipped — none of which names a conclusion. It says whether an answer already stands, never which answer. verdict_produced would not do: that is true whenever the driver reached a verdict, including when nothing published.

Anything but success withdraws, so an outcome the step cannot read clears rather than leaving a thumb standing for a verdict that may not be there.

New case cancelled-after-publish (success>cancelled, verdict success): the answer step posts the thumb, the withdrawal is skipped, the thumb survives. Dropping the term fails it plus three more; inverting it fails 16. Two more mutations now caught: deleting id: verdict, and moving the withdrawal ahead of Post the verdict — both silently empty the read forever.

Comment thread test/seidroid-review/conditions.py Outdated
# unmodelled.
runs = verdict is not False
env = " ".join(str(v) for v in (step.get("env") or {}).values())
reads = [k for k in CONCLUSION_INPUTS if k in env]

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] The invariant sweep only inspects step.get("env"), so it misses a step that interpolates ${{ steps.drive.outputs.check_path }} (or verdict_produced) directly into run:, with:, or its own if:. The README claims the check is "stated over the file as well as over a table -- so a step added later is covered"; a later step using inline interpolation would pass silently, which is exactly the case the invariant exists to catch.

Cheap fix: build the haystack from the whole step, e.g. haystack = yaml.safe_dump(step) (or join env, run, with, and if) before the CONCLUSION_INPUTS membership test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct, and fixed in 1ba2010. The haystack is now yaml.safe_dump(step), so run:, with:, if: and env: are all covered.

Proved with the exact case you name — echo "check was ${{ steps.drive.outputs.check_path }}" added to the withdrawal step's run:, touching no env: key:

env-only haystack contains check_path:    False
whole-step haystack contains check_path:  True
FAIL Withdraw the reactions on a cancelled run runs on a cancelled run and reads check_path

The README claim now holds. A second file-level check went in beside it: every steps.<id> a reaction condition reads must be a real id on an earlier step, because deleting the id or reordering the step empties the read forever with no error.

@github-actions
github-actions Bot dismissed their stale review September 7, 2026 20:02

Superseded: latest AI review found no blocking issues.

@bdchatham

Copy link
Copy Markdown
Contributor Author

All three findings fixed in 1ba2010. Both inline ones answered in thread.

On the body note. The always() rationale was already gone when this landed — gh pr edit had replaced that section before the review, so the copy under review was stale. The body is now rewritten against the current diff regardless, since finding 1 changed it again. It opens with what ships (!cancelled() on Answer the request, unchanged from base; a new last step under cancelled() && steps.verdict.outcome != 'success'), and always() appears only where it is named as the shape that was wrong and why.

Also recorded, per the non-blocking note: a section on the cost. The list-and-delete block is duplicated between the two steps, so me="github-actions[bot]" and the deletable content set are two copies kept in step by hand. That is the price of the separate step, and it buys the no-POST property; sharing the block means one step doing both jobs, which is the shape that produced the first blocker. Actions offers no way to share a script between steps without a checkout, and YAML anchors are unsupported. Both harnesses cover both copies, so a drift fails rather than ships. GraphQL removeReaction is what would collapse it — no login, no listing.

Harnesses: reactions.sh 49 assertions / 26 cases, conditions.py 39 assertions, incumbent run.sh 165 assertions. All green on the current base. actionlint identical base vs branch. shellcheck -S warning clean.

On --force-with-lease: taken, thank you — I will use it pinned to my own last-pushed sha rather than building a merge next time.

Nothing ran on a GitHub runner.

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1ba2010. Configure here.

Comment thread .github/workflows/seidroid-review.yml Outdated

@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 new Withdraw the reactions on a cancelled run step is correctly placed last, correctly gated on cancelled() + steps.verdict.outcome != 'success', uses the same github.token identity (github-actions[bot]) as the steps that post the reactions, and reads nothing that could let it state an outcome — the core design holds up. The two new harnesses (reactions.sh, conditions.py) are wired into CI and meaningfully cover the behaviour; only minor gaps and a stale comment remain.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • Note: I could not execute test/seidroid-review/reactions.sh or conditions.py in this environment (sandbox denied python3), so the harnesses were reviewed statically rather than run. The condition model, the gh stub's argument parsing, and each case's expected reaction set line up with the real step scripts on inspection.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread .github/workflows/seidroid-review.yml Outdated
# a conclusion. It says whether an answer already stands, never which answer it
# would be, so reading it cannot give this step an outcome to state.
#
# Anything but success withdraws. An outcome this step cannot read therefore

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] steps.verdict.outcome only speaks for this run, but the reactions on the trigger comment can outlive a run. Reachable sequence: run 1 on comment A completes and leaves +1 on A plus a verdict comment on the PR; someone re-runs the workflow (the comments elsewhere note a re-run replays the same trigger comment id); a newer @seidroid review (comment B) cancels the re-run before it reaches Post the verdict, so steps.verdict.outcome is skipped/empty; this step then deletes run 1's +1 along with the eyes. Comment A ends bare even though the verdict it asked for is still upserted on the pull request — exactly the "reads as never answered" failure the != 'success' gate was added to prevent, one run earlier.

Narrow, and arguably an acceptable trade for the "clear on uncertainty" rule this step is built on — but the surrounding comment states the invariant unconditionally ("a thumb that answers a verdict already on the pull request survives a later cancellation"), which is only true within a single run. Worth either narrowing the withdrawal to eyes when this run posted no thumb, or recording the cross-run limit in the comment so the next reader does not rely on the stronger claim.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 6e7c8c9, and I narrowed rather than documented. Answer the request gains id: answer, and the withdrawal reads its outcome to decide what this run may take:

steps.answer.outcome withdrawn
skipped — never touched the comment, so a thumb there is an earlier run's eyes only
success — took the stale thumb, posted its own, published nothing +1 -1 eyes
failure, cancelled, unreadable +1 -1 eyes

Narrowed because the fix is cheap, keeps the structural property (still no POST; an outcome is four words with no conclusion in it), and a cancellation lands during the driver far more often than after step 13 — so the sequence you describe is the common one, not the rare one.

One case is left over, and the comment now states it instead of claiming the invariant. A run that answers — withdrawing an earlier thumb and posting its own — then cancelled before publishing leaves the comment bare. The answer step already took the earlier thumb, so nothing at the end of the job can restore it, and knowing it happened needs a read of the pull request this step deliberately does not make.

And one tension I am flagging rather than reading loosely. "Given a run cancelled by a newer request, its trigger comment carries no reaction from this bot" now fails on one path: a cancelled re-run leaves an earlier run's thumb. The intent holds — the comment is not wearing 👀 with no answer coming, because the answer is on the pull request. Satisfying the literal wording restores this defect.

Cases: rerun-cancelled-before-answer (earlier thumb survives), rerun-cancelled-human, rerun-answered-then-cancelled, and the failure/cancelled/unreported arms. Mutation: the skipped arm taking all three fails EARLIER THUMB SURVIVES plus three more.

return select(sys.argv[2:])
with open(sys.argv[1], encoding="utf-8") as handle:
doc = yaml.safe_load(handle)
steps = {s["name"]: s for s in doc["jobs"]["review"]["steps"] if "name" in s}

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] steps drops any step without a name, so the sweep below — the one advertised in the README as "stated over the file rather than over a table, so a step added later is covered" — silently skips unnamed steps. Every step in the review job is named today, so nothing is missed now; but a later - uses: actions/checkout@v7 with no name: (the usual shape) would be invisible to the check_path/verdict_produced invariant that is the whole point of the check. Keying the sweep off the raw doc["jobs"]["review"]["steps"] list (falling back to uses/index for the label) keeps the guarantee the README claims.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 6e7c8c9. Both file-level checks now walk every job's raw steps list and search the whole step, so neither needs telling where to look.

Your prediction was already true, not just hypothetical: the guard job carries an unnamed step today. My sweep only covered review, so nothing was missed — but it now covers both jobs.

Proved with the shape you name:

raw steps: 18   reachable by name: 17   invisible: 1
FAIL step 17 runs on a cancelled run and reads verdict_produced

The label falls back to uses and then to the index.

On your broader point — I found two more. The id check reached only if, so the withdrawal's new env read of steps.answer.outcome would have gone unchecked; it now searches the whole step too. And run_case exported each per-case knob without clearing it, so an ANSWERED_AS override leaked forward and silently disarmed a later case — my own harness caught that one only because the leak made a case fail. Knobs are unset at the top of every case now.

That is five checks narrower than their own claim on this PR. The pattern is the same each time: the check named a property but sampled one key, one name, or one argument. Each fix reads the file instead.

Comment thread test/seidroid-review/conditions.py Outdated

import yaml

# step -> job state -> does it run? mode is 'review' with a comment id unless said.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] Stale duplicate: lines 31-36 are an earlier draft of the block at 37-46, and their key description is now wrong — EXPECTED's keys are 4-tuples (state, mode, comment id, verdict outcome), not job state. Deleting 31-36 leaves the correct header.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Deleted in 6e7c8c9. You were right that the key description was wrong — EXPECTED's keys were already 4-tuples, and they are now (job state, mode, comment id, posted).

@bdchatham

Copy link
Copy Markdown
Contributor Author

All four findings fixed. 2ffa24a, rebased on 2f58b80. Each answered in thread.

Finding 1 — narrowed, not documented. Answer the request gains id: answer; the withdrawal takes the eyes alone when that step's outcome is skipped, because a thumb on the comment then belongs to an earlier run. Narrowed because a cancellation lands during the driver far more often than after step 13, so your sequence is the common one. One case is left over and the comment states it: a run that answers, then dies before publishing, leaves the comment bare — the answer step already took the earlier thumb, so nothing at the end of the job can restore it. And one tension I am flagging rather than reading loosely: the stated criterion "its trigger comment carries no reaction from this bot" now fails on a cancelled re-run, which keeps an earlier honest thumb. The intent holds; the literal wording does not.

Finding 2 — and two more of the same class. Both file-level checks now walk every job's raw steps list and search the whole step. Your prediction was already live: the guard job carries an unnamed step today. The id check also reached only if, so the withdrawal's new env read would have gone unchecked. And run_case exported per-case knobs without clearing them, so an ANSWERED_AS override leaked forward and disarmed a later case.

Finding 3 — deleted.

Finding 4 — Bugbot, and it was right. steps.verdict.outcome cannot carry "did the comment land": Post the verdict tolerates a refused POST and ends on a swallowed call, so it exits 0 and reads success either way. The gate now reads a posted output written from the POST's own result. conditions.py models the outcome beside it, derived as the runner sees it, so a regression to the outcome fails an assertion rather than crashing the model.

Five checks on this PR ran narrower than their own claimenv-only haystack, id-blind model, name-keyed sweep, leaking knobs, and a gate reading an exit code. Same shape each time: the check named a property but sampled one key, one name, or one argument.

result
reactions.sh 62 passed, 0 failed (32 cases)
conditions.py 75 passed, 0 failed
incumbent run.sh 165 passed, 0 failed

actionlint identical base vs branch (37 whole-dir, 4× SC2102:info, 0 on the test workflow). shellcheck -S warning clean. YAML parses.

Base moved twice mid-review and neither move was trusted: harnesses and actionlint re-ran on each new history, and I checked the rebase by reading back the step ids, the gate and the withdrawal step's POST count rather than its exit status.

Nothing ran on a GitHub runner.

bdchatham and others added 6 commits September 7, 2026 13:43
Two `@seidroid review` comments in quick succession cancel the first run. The
answering step took `!cancelled()`, so the concurrency group skipped it, and the
acknowledgement 👀 was already on the older comment. The newer run answers its
own comment id, so nothing ever read the older one again and it wore the eyes
for good.

`always()` on that step, which is safe because the arm it admits is the one that
already handles an absent conclusion: a cancelled run reaches the step with no
check file, and that arm withdraws all three of this bot's reactions and posts
no thumb. It spends four API calls, takes no position on the pull request, and
starts none of the work a cancellation stops.

`issues: write` stands. PLT-1159 asked to drop it and react through GraphQL
`addReaction` under `pull-requests: write`. The incumbent evidence does not
support that: ai-review's reactions are posted by the App installation token,
not by GITHUB_TOKEN, so its `permissions:` block never governed them. GitHub
publishes no permission data for any GraphQL mutation. The permission comment
now records both facts.

PLT-1166

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three steps in the review job carry no `if:` at all, so the runner wraps them as
`success()`. Naming `!cancelled()` as what every other step takes was wrong. All
fifteen skip a cancelled run, whichever route they take there, and that is the
fact the reader needs.

PLT-1166

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n cannot post from

A cancellation arriving after `drive` finishes leaves check_path and
verdict_produced populated. `always()` on `Answer the request` therefore read a
real conclusion and thumbed the request, while `Post the verdict` skipped and the
verdict never reached the pull request. A thumb reads as an answer, so that is
worse than the stale eyes it was meant to fix.

`Answer the request` keeps `!cancelled()`. The withdrawal moves to a step of its
own, last in the job, under `cancelled()`. That step reads no check file, no
verdict_produced and no conclusion, so no value a cancellation leaves populated
can make it state an outcome.

Last in the job rather than beside the step above, because a condition is
evaluated when its step is reached: a clear placed earlier is already skipped by
the time a cancellation lands on a later step. From the end it also withdraws a
thumb `Answer the request` had just posted, so a cancellation arriving during or
after that step heals.

The named fix -- gate the conclusion read on `cancelled()` inside the script --
is not available. `cancelled()` is readable only in a step or job `if`;
PipelineTemplateEvaluator.EvaluateStepEnvironment creates its context without
the expression state CancelledFunction asserts on, so `env: X: ${{ cancelled() }}`
fails the step before its condition runs, and actionlint rejects the workflow.

Harness: reactions.sh runs all three reaction steps against a gh stub, 38
assertions over 22 cases, including a cancelled run with the verdict outputs
populated. conditions.py covers what a shell harness cannot see, 33 assertions,
and states the invariant over the file as well as a table -- no step reading
check_path or verdict_produced may run on a cancelled job.

PLT-1166

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A cancellation can arrive once `Post the verdict` has upserted the comment and
published the check -- during thread resolution, say. The withdrawal step then
deleted the thumb `Answer the request` had posted, leaving a fully published
review with no reaction on the request that asked for it. That reads as never
answered: this step's own defect, one window later.

The step's condition now also names `steps.verdict.outcome != 'success'`. It
still cannot state an outcome, and the reason is stronger than an absent input:
the step contains no POST, so no code path in it adds a reaction whatever it is
told. The one fact it gains is the posting step's outcome -- success, failure,
cancelled or skipped -- and none of those four words names a conclusion. It says
whether an answer already stands, never which answer it would be.

Anything but success withdraws, so an outcome the step cannot read clears rather
than leaving a thumb that stands for a verdict which may not be there.

The invariant sweep now reads the whole step rather than its `env` block. An
inline ${{ steps.drive.outputs.check_path }} in `run:`, `with:` or `if:` reaches
the same value and passed unseen before.

Two harness gaps closed. Each reaction case now derives which step runs from
`conditions.py --select` instead of naming it, so the shell layer and the
condition layer cannot drift. And a case declares the moment the cancellation
arrived, as two job states, so a late-cancellation case proves its thumb through
the answer step rather than seeding one by hand.

`conditions.py` also checks that every `steps.<id>` a reaction condition reads is
a real id on an earlier step. Deleting the id, or moving the withdrawal ahead of
`Post the verdict`, otherwise empties the read forever with no error.

reactions.sh: 49 assertions over 26 cases. conditions.py: 39 assertions.

PLT-1166

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

A re-run replays the trigger comment id, so the comment can already carry a thumb
from an earlier run whose verdict IS on the pull request. A re-run cancelled
before it answered took that thumb along with its own eyes, leaving the comment
bare while the verdict it asked for still stood. That is the "reads as never
answered" failure, one run earlier.

`Answer the request` gains `id: answer`, and the withdrawal reads its outcome to
decide what this run may take. skipped is the only value meaning "never touched
the comment", and there the withdrawal takes the eyes alone. Once the answer step
has run, every reaction on the comment is this run's own, and this run published
nothing, so all three go. failure, cancelled and an unreadable value all clear: a
thumb standing for a verdict nobody published is the worse of the two wrongs.

The structural property holds. The step still has no POST, and an outcome is four
words about another step with no conclusion among them.

One case is left over and the comment now records it rather than claiming the
invariant outright. A run that answers, withdrawing an earlier thumb and posting
its own, and is then cancelled before publishing, leaves the comment bare. The
earlier thumb is already gone by then, so nothing here can restore it.

Both file-level checks now walk every job's RAW steps list and search the whole
step. Keyed off a name they dropped an unnamed step, and `- uses: ...` with no
`name:` is the usual shape -- the step most likely to arrive later was the one
they could not see. The id check reached only `if`, so the withdrawal's new `env`
read would have gone unchecked.

Also removes a stale duplicate of the EXPECTED header whose key description no
longer matched, and clears each per-case harness knob at the top of run_case: an
ANSWERED_AS override leaked into every later case and silently disarmed one.

reactions.sh: 60 assertions over 32 cases. conditions.py: 77 assertions.

PLT-1166

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ed, not on an exit code

`steps.verdict.outcome` cannot carry the fact the gate needs. `Post the verdict`
tolerates a refused comment POST and ends on a call whose failure it swallows, so
it exits 0 and its outcome reads `success` whether the verdict landed or not. A
later cancellation then skipped the withdrawal and left the thumb `Answer the
request` posted, standing for a review nobody can see.

That step already tracks the POST's result in a shell variable. It now writes it
as a `posted` output, and the withdrawal reads
`steps.verdict.outputs.posted != 'true'`. One boolean about another step, written
from the POST's own result, with no conclusion in it -- so the structural property
holds: the withdrawal step still has no POST.

`conditions.py` models `steps.verdict.outcome` beside `posted`, derived rather
than passed: `success` whenever that step reported at all, which is what the
runner sees. A condition that goes back to reading the outcome therefore fails an
assertion instead of crashing the model.

New case `cancelled-publish-failed`: the answer step posts its thumb, the POST of
the verdict is refused, and the withdrawal takes the thumb. Reverting the gate to
the outcome fails it, and fails the matching condition row.

Reported by Cursor Bugbot on 1ba2010.

PLT-1166

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

@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 sound: the withdrawal step is genuinely last in the job, reads only two non-conclusion booleans (steps.verdict.outputs.posted, steps.answer.outcome), contains no POST path, and Post the verdict writes its posted output on both branches before its early exit 0. Two non-blocking notes, both about the new test harnesses rather than the workflow behaviour.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • bin-reactions/gh and bin/gh are now two independent gh stubs with overlapping surface (-X, --jq, --paginate, -f). Not worth unifying today, but a third harness would be the point to factor out the argument parser.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

TRIGGER_ID: ${{ needs.guard.outputs.comment_id }}
# Whether this run ever reached the step that answers. See the withdrawal set
# below. Four words about another step; none of them a conclusion.
ANSWERED: ${{ steps.answer.outcome }}

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 env: key is the single link between the two steps, and neither new harness asserts it exists.

extract.py copies only a step's run: block, and reactions.sh exports ANSWERED itself (line 127). conditions.py's steps.<id> check searches the dumped step for steps.answer., so it catches a dangling reference — but not a renamed or deleted key.

Concretely: rename this to ANSWER_OUTCOME and leave ${ANSWERED:-} in the run: block. steps.answer. is still present under the new key, so conditions.py passes; reactions.sh exports ANSWERED itself, so all cases pass. In production ${ANSWERED:-} is empty, which falls to the * arm and takes +1 -1 eyes — exactly the case rerun-cancelled-before-answer exists to prevent, silently stripping an earlier run's thumb whose verdict is on the pull request.

The same holds for Answer the request's CHECK / VERDICT_PRODUCED. Cheapest fix: assert the env mapping in conditions.py (e.g. that the withdrawal step's env.ANSWERED is ${{ steps.answer.outcome }}), or have extract.py emit the step's static env keys so the harness inherits the names rather than restating them.

for ref in sorted(set(re.findall(r"steps\.([A-Za-z0-9_-]+)\.", dump(st)))):
if ref not in ids:
check(f"{where} reads steps.{ref}, which is no step's id", True, False)
elif ids[ref] > i:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] ids[ref] > i lets a step read its own id pass. A step can never usefully read steps.<its own id> from any key — if:, env: and with: are all evaluated before the step runs, and run: sees the value the step has not written yet — so the read is empty in every case, which is the same silent failure the check exists to catch. ids[ref] >= i covers it.

The withdrawal being last is load-bearing, and nothing checked it. The runner
evaluates a condition when it reaches the step, so any step placed after the
withdrawal is a step during which a cancellation leaves the eyes standing: the
withdrawal was already evaluated and skipped by then.

`conditions.py` now checks the position rather than any one ordering. Moving the
step ahead of `Post the verdict`, ahead of the resolve step, ahead of the
no-verdict report, or appending a step after it all fail. The id-ordering check
caught only the first of those, because `Post the verdict` still ran earlier in
the other three.

The README said "Two checks" where there are three, and described the withdrawal
gate as `Post the verdict` having "completed" rather than having landed its
comment. Both now match what the file does.

PLT-1166

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bdchatham
bdchatham force-pushed the fix/clear-the-reactions-on-a-cancelled-run branch from 2ffa24a to 0d925ab Compare September 7, 2026 20:46
@bdchatham

Copy link
Copy Markdown
Contributor Author

Rebased on 98c2619 (#102). 0d925ab. Ordering survived: the withdrawal is still the last step of the review job, at 17 of 17. The resolve step is at 15 and Report a review that reached no verdict at 16 — in the base those two are the last pair, so the withdrawal lands after both.

I closed the ordering class instead of adding the mutation. conditions.py now asserts the withdrawal is the last step of the review job, because any step after it is a step during which a cancellation leaves the eyes standing. Four mutations fail it, and only the first was already covered:

mutation caught by
ahead of Post the verdict position check and the id-ordering check
ahead of the resolve step position check alone
ahead of the no-verdict report position check alone
any step appended after it position check alone

The id-ordering check passes the middle two, because Post the verdict still runs earlier in both. A single mutation would have tested one ordering; the position check covers a step appended next month.

One conflict, in the harness README, resolved keeping both sides. workflow-test-self.yml merged cleanly: place-findings keeps your renamed Place findings and resolve threads and the reaction job keeps its own name. While there I fixed the README saying "Two checks" where there are three, and describing the gate as Post the verdict having "completed" rather than having landed its comment — the same claim-versus-reality gap as the other five.

result
reactions.sh 62 passed, 0 failed
conditions.py 76 passed, 0 failed
run.sh (#102) 271 passed, 0 failed

actionlint identical base vs branch (37 whole-dir, 4× SC2102:info, 0 on the test workflow). shellcheck -S warning clean. YAML parses. Withdrawal step still contains zero POSTs.

For #103. It was written against two reaction steps; this branch leaves three, with six hardcoded issues/comments paths rather than four — one in Acknowledge the trigger, three in Answer the request, two in the withdrawal step. Whichever of us rebases second has to reach all three steps.

Nothing ran on a GitHub runner. Third base move, third full re-run rather than a carried result.

@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 withdrawal step, the new answer/verdict step ids and the two harnesses are coherent, and the reaction logic reads correctly across the cancellation windows the description enumerates. Two non-blocking issues: a backtick command substitution in the new shell harness, and no test coverage for the posted output the whole gate depends on.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • The "cancelled mid-publish" row of the PR's table says the withdrawal takes a thumb that "would stand for nothing". That is not guaranteed: Post the verdict writes posted= only after the comment POST returns, so a cancellation that kills the step between a successful POST and that line leaves posted empty and the withdrawal strips a thumb that does answer a published verdict. It is a strictly narrower instance of the gap already documented for the answer step, but the table currently reads as if the row is safe rather than best-effort.
  • run_case in test/seidroid-review/reactions.sh accepts a SKIP_ACK knob that no case ever sets, so the acknowledgement always runs. Either exercise it or drop it.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

# failure path below ends on a call whose failure it swallows, so the step
# exits 0 whether the comment landed or not, and its outcome reads success
# either way.
echo "posted=$posted" >> "$GITHUB_OUTPUT"

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 output is the single fact the whole withdrawal gate rests on, and nothing tests that it is produced. reactions.sh never runs Post the verdict — it injects posted as a fixture argument to run_case — and conditions.py models the value in Ctx.values. So both harnesses stay green if this line is deleted, moved above the POST, or made unreachable by an early exit added higher in the script; the failure would only show up as a silently withdrawn thumb on a live run.

Given that the mutation table deliberately covers id: verdict being deleted and the gate regressing to steps.verdict.outcome, the producing side deserves the same treatment. Either extract this step into reactions.sh against the gh stub and assert posted=true / posted=false land in $GITHUB_OUTPUT on both POST outcomes, or add a conditions.py check that the step whose id the withdrawal reads writes posted= to $GITHUB_OUTPUT at all.

check "withdraw ran" 1 "$(ran 'Withdraw the reactions on a cancelled run')"
check "left" "" "$(left)"

echo "== THE VERDICT COMMENT WAS REFUSED. `Post the verdict` tolerates that and exits 0,"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] The backticks are inside double quotes, so bash treats them as command substitution: it executes Post with args the verdict, prints Post: command not found to stderr, and substitutes empty — the banner comes out as == THE VERDICT COMMENT WAS REFUSED. tolerates that and exits 0,. set -e is not on, so the harness still passes, which is why it slipped through; shellcheck's SC2006 is style, below the -S warning threshold you ran at.

Use single quotes for this banner, or escape the backticks. The other multi-line banners in this file (e.g. lines 156-157, 187-188) avoid backticks and are fine.

@bdchatham
bdchatham merged commit 2f7efad into feat/seidroid-review Sep 7, 2026
8 checks passed
@bdchatham
bdchatham deleted the fix/clear-the-reactions-on-a-cancelled-run branch September 7, 2026 20:53
bdchatham added a commit that referenced this pull request Sep 7, 2026
…let a caller name it (#103)

Four tickets, one region: the guard's job condition, its `parse` and
`Admit the
request` steps, and the two `workflow_call` inputs they read.

**PLT-1147 — accept `pull_request_review_comment` and
`pull_request_review`.**
Both events are admitted. Every read in `parse` and `Admit the request`
takes the
comment key or the review key, whichever the event populated: a review
body names
its author under `review.user` and its id under `review.id`, and a step
reading
`comment.*` alone saw an empty body there and refused in silence. The
two
diff-side events are held to their creating action, because a
`dismissed` review
replays the body of the review it dismisses — a caller wiring that type
would
re-review on every dismissal.

**PLT-1153 — restore `allowed-bots`.** A JSON array of exact logins,
default
`[]`. Checked in the guard's job condition, so an unlisted bot starts no
runner,
and again in `Admit the request`. Exact and case-insensitive both times,
as
ai-review.yml checks it. A listed bot skips the team read — a bot is not
a team
member — and is held to the fork check, the skip label and the command
grammar.
The job condition now admits a person on `author_association` and a bot
only by
login, because association does not discriminate a bot: one with write
access
carries MEMBER like anyone else.

**PLT-1161 — refuse an unsupported event.** A first step names the event
that
arrived and the four this workflow handles, and exits 1. It runs before
the
identity mint and before the secret check, so a mis-wired caller spends
no
credential. The guard's condition gained a clause admitting an
unsupported event
for exactly that step: without it the job is skipped, every job after it
is
skipped, and the run reports success having done nothing. `pull_request`
is
excluded from that clause — a `pull_request` close skips the guard
deliberately,
and the review job reads that skip as its own trigger.

`pull_request_target` is refused apart, with its reason: it runs with
the base
repository's secrets and a writable token over a head this workflow did
not check
out. Nothing here checks anything out today; the refusal is the control
that does
not depend on that staying true.

**PLT-1164 — restore `trigger-phrase`.** Default `@seidroid`, and the
pattern is
built from it rather than hardcoded — in the command grammar and in the
repository-target refusal beside it.

## The two decisions the tickets asked for

**The optional `@` stays.** A person who types the phrase without the
mention
still means it, and the wider form costs nothing here. Whole-line
anchoring is
what makes it safe, and it is intact. The non-overlap with
`ai-assistant.yml` is
now measured rather than argued: that workflow's reply condition
requires
`contains(body, '@seidroid')`, so a bare `seidroid review` reaches this
workflow
alone. Group 16 of the harness evaluates the assistant's own condition
beside the
parse for five bodies and records which tool answers each.

Two bodies both tools answer today, and both predate this change:
`@seidroid
review close`, and a body carrying the command on its own line amid
prose. The
assistant reserves the exact body only, and neither of those is it.
Whole-line
anchoring is what admits the second — and it is also what keeps `Do we
need
@seidroid review here?` from starting a review, so the overlap is the
price of
the property the ticket told me not to lose. The harness asserts the
present, so
a later change that closes either overlap fails a case and has to
re-read it.

**The phrase's shape is constrained, not escaped.** After stripping one
leading
`@`, the phrase must be letters, digits, `_` and `-`. None of those is
an ERE
metacharacter, so the pattern carries the phrase verbatim with no
escaping.
Anything else falls back to `@seidroid` with a warning, which is what
`guidelines-file` does with a name it cannot trust. Escaping would have
to cover
every ERE metacharacter correctly forever; a character class is one
thing to
read. Two harness cases show what the constraint buys: with `@my.bot`,
`@myXbot review` does not match; with `@a|b`, the line `a note about the
diff`
does not match. Unconstrained, the `|` would split the pattern into
`^[[:space:]]*@?a` — which every line starting with `a` matches.

## One deliberate step outside the stated region

Three reaction steps build their reactions URL from a new guard output,
`comment_api`, instead of a hardcoded `issues/comments`: `Acknowledge
the
trigger` (one path), `Answer the request` (three) and, since #100,
`Withdraw the reactions on a cancelled run` (two). Six paths, three
`env:` keys.

The endpoint differs per event — `issues/comments/{id}/reactions` for a
conversation comment, `pulls/comments/{id}/reactions` for a diff-thread
one — and
without this PLT-1147's acknowledgement would post to a path that holds
no
object, and the two steps that withdraw it would look for it somewhere
else
again. That is the trap the ticket names, and it cannot be fixed from
inside the
guard alone. The review job already holds both scopes: GitHub grants the
first to
Issues and the second to Pull requests.

Two properties of #100 survive the edit, and both are asserted rather
than
argued. The withdrawal step is still the **last** step of the review job
— index
16 of 17, and `conditions.py` checks the position rather than one
ordering. And
it still contains **zero POSTs**: `-X POST`, `--method POST` and `-f
content`
each appear 0 times in it, `DELETE` is the only verb it names, and
`reactions.sh`
asserts the POST count. Adding an `env:` key changes neither.

`repos/{owner}/{repo}/issues/comments/{id}` in two other steps is
untouched:
those delete comments this workflow posted on the conversation, not the
trigger.

## One acceptance criterion that REST cannot meet

GitHub publishes no reactions endpoint for a pull request **review**.
Only the
GraphQL schema makes a review reactable, and PLT-1159 already proposed
that route
and was declined. So a command in a review body starts a review,
`comment_api`
and `comment_id` both go out empty, both reacting steps skip on their
existing
condition, and a `::notice::` in the run log says the review started and
why no
reaction landed. The review, the verdict comment and the inline findings
all
still arrive. A diff-thread comment gets the full acknowledgement.

That is a read claim, not a measured one — see below.

## The review round

Seven findings taken.

**An unset `allowed-bots` no longer takes the run down.** A
`workflow_call`
default applies only to an input the caller OMITS, so
`allowed-bots: ${{ vars.SOMETHING }}` with that variable unset arrives
as `''`,
and `fromJSON('')` is not `[]`. The condition reads
`fromJSON(inputs.allowed-bots || '[]')`, so empty takes the documented
default
and denies every bot, while a non-empty non-JSON value still fails
loudly. The
fix holds under either evaluation order, which turned out to matter —
see below.

**`gha.py` short-circuits, because the runner does.** Or and And return
on the
first truthy or falsy operand and never evaluate the rest. My model
evaluated
eagerly, and one shipped assertion therefore stated the opposite of what
a real
event does: with a malformed list and a human MEMBER, the person branch
is
already true, so `fromJSON` is never reached and the guard admits.
Re-derived per
requester — a person yields `true`, a bot yields `error` (the requester
whose
admission depends on parsing the list), an automatic review yields
`true`. This
corrects a claim in my own earlier report, where I had listed eager
evaluation as
read-not-measured and had it backwards.

**Group 16 now measures the overlap on all three events.** `claims()`
was keyed
to `issue_comment`, so it measured the division of labour on the one
path that
already had it and inferred the two this branch adds. It takes an event
now, and
every body runs on all three plus an empty review body. The overlap is
identical
on all three — measured, not reasoned. 11 assertions to 33.

**`ai-assistant.yml` is in `workflow-test-self.yml`'s `paths:`.** Group
16 states
an invariant about that file, so an edit there could break it and
surface later as
a red `Guard the request` on an unrelated change. I audited every file
the three
harnesses read: it was the only one outside the filter, and
`conditions.py` takes
its target from the CI command line, which names a watched file. No
other
cross-file assertion has this shape.

**The log id and the reactable id are two facts.** `comment_id` is the
reactable
object and goes out empty where nothing can react; the driver's
`--trigger-id`
was reading it, so a review-body dispatch had silently stopped carrying
a label.
The guard emits `trigger_id` beside it, always populated, and only
`Drive session + collect verdict` moved to it — no step condition
changed, so
`conditions.py`'s context model needed nothing. A new group asserts
which of the
four outputs each consumer reads.

**A comment claimed something false about the payload.** "No comment
event
carries a head repository" holds for `issue_comment` alone;
`pull_request_review_comment` and `pull_request_review` both carry
`pull_request.head.repo.id` and `.base.repo.id`. I corrected the
sentence rather
than widening the branch, because the label check twelve lines below
reads the
same `GET /repos/{owner}/{repo}/pulls/{n}` endpoint unconditionally on
all three
comment paths: reading the payload here would drop one of two identical
round
trips and neither the failure mode nor the dependency. The comment now
names the
one event that needs the API and records what a payload-keyed branch
would have
to preserve. The bigger saving is collapsing those two reads of one
endpoint into
one, available on all four paths — that belongs in a ticket, because it
moves the
fork check.

**The permissions comment explains both scopes**, one per collection,
and names
what pruning either costs: the reaction fails on the path that scope
serves, and
all three reacting steps treat a lost reaction as a courtesy and only
warn.

## Verification

Everything below ran on this machine. Nothing ran on a GitHub runner.

`test/seidroid-review/run-guard.sh` is new, beside the placement
harness. It
reads five steps out of the shipped YAML by name or id, runs them under
`bash`
against a `gh` stub of its own, and evaluates the two job conditions,
the
per-event `env:` mappings and the declared input defaults with a new
`gha.py`.

```
$ test/seidroid-review/run-guard.sh
assertions: 241 passed, 0 failed

$ test/seidroid-review/run.sh                 # placement and resolution, unchanged
assertions: 271 passed, 0 failed

$ test/seidroid-review/reactions.sh           # 62 before, +15 for the collection
assertions: 77 passed, 0 failed

$ python3 test/seidroid-review/conditions.py .github/workflows/seidroid-review.yml
assertions: 77 passed, 0 failed
```

`reactions.sh` needed the change, not just the extra cases. Its
`run_case` did not
export `COMMENT_API`, so every extracted step died on an unset variable
under
`set -u` and 25 of its 62 assertions failed with every API count at
zero. The
default is set there now, and a group varies it: three steps, six paths,
and a
`pulls/comments` case asserting nothing reached `issues/comments`.

`conditions.py` went from 76 to 77 on its own. Two of its checks walk
every job's
raw steps list, so the refusal step this branch adds to the guard job
earns one
more assertion without anything being written for it.

`gha.py` models four GitHub expression semantics the conditions rest on:
case-insensitive string comparison, `||` and `&&` yielding one operand
each,
**both short-circuiting**, and `contains` over an array testing
membership rather
than substring. `--selftest` checks all eighteen readings, and group 0
of the run
fails if any is wrong. The model is read from GitHub's published
semantics; it is
not measured against a runner.

**Mutation check.** 41 mutations of the shipped workflow, applied one at
a time,
each killed at least one assertion. **0 alive, 0 skipped.** The sweep
runs all
four harnesses per mutation, because one edit spans steps three of them
cover —
a mutation only `reactions.sh` or `conditions.py` can see would have
survived a
sweep that ran the guard harness alone. Four of the 41 cover this review
round:
dropping the empty-input fallback, holding `trigger_id` back with the
reactable
id, and pointing either the driver or the acknowledgement at the other's
id.

Among them: dropping either new event from the condition, dropping the
creating-action gates, reading `allowed-bots` as a string rather than
JSON,
dropping the `pull_request_target` arm, emitting the id where no
endpoint reaches
it, dropping the phrase's shape check, hardcoding the phrase in either
pattern,
dropping the whole-line anchors, requiring the `@`, reading only the
`comment.*`
payload keys in either step, matching a listed bot by substring or
case-sensitively, letting the once-per-PR gate reach a comment, and
applying the
requester check to a teardown.

Six mutations cover the six reaction paths — one in the acknowledgement,
three in
the answer, two in the withdrawal — and each is killed by at least two
assertions. Two more cover #100's properties: appending a step after the
withdrawal is killed by `conditions.py`'s position check, and adding a
POST to
the withdrawal step is killed 21 times by `reactions.sh`.

**actionlint, before and after.** Base `2f7efad`, all workflows:

```
6 [action]   30 [shellcheck]   1 [syntax-check]
```

This branch, all workflows: the identical set, finding for finding —
compared as
`rule + code`, not just as a count, and identical per file too.
`seidroid-review.yml`'s own four are the pre-existing `SC2102:info`. The
rest are
in `ai-assistant.yml` (3), `ai-review.yml` (4), `release-check.yml` (22)
and
`release-publish.yml` (4), all untouched.

`workflow-test-self.yml` lints clean. Both files parse under PyYAML.

## Rebase note

Written against `3544bf5`; rebased three times as the base moved, to
`b1b51f8`
(#97), `98c2619` (#101, #102) and `2f7efad` (#100). Head is `a31efa6`.

The third rebase conflicted in four files:

- **`seidroid-review.yml`** — one hunk, in `Acknowledge the trigger`:
#100
rewrote the comment above the POST while this branch rewrote the URL
below it.
  Union.
- **`workflow-test-self.yml`** — a three-way union. Three jobs now,
under
distinct names: `Place findings and resolve threads`, `The reaction
steps`,
  `Guard the request`.
- **`README.md`** — two hunks; one document with a section per harness.
- **`.gitignore`** — union of three extractor lists.

One collision the conflict markers did not show: `reactions.sh` and
`run-guard.sh` both extract `Acknowledge the trigger` and `Answer the
request`,
and both wrote them to `ack.sh` and `answer.sh`. Running both would have
one
overwrite the other's extraction. This branch is the newcomer, so it
moved:
`guard-ack.sh` and `guard-answer.sh`. The README now says which harness
asks
what of those two steps.

Everything was re-run on the rebased history rather than carried
forward: all
four harnesses, the full mutation sweep, the actionlint comparison
against the
new base, and the group counts, recounted from the shipped file
(unchanged this
time — 204 over the same seventeen groups).

**The sweep caught itself.** Its first pass on this base reported 36
killed and
**one SKIP**: `M28`, which hardcodes the issue collection in the
answering step's
read. After this branch routed the withdrawal step through
`COMMENT_API`, that
step's read became byte-identical to the answering step's, so `M28`'s
anchor
matched twice and stopped applying. A sweep that only counted kills
would have
read 36/36 and looked clean. `M28` now carries the comment line above
the call,
which the two steps do not share, and is killed by four assertions
across two
harnesses.

## What rests on reading rather than measurement

- That GitHub sends `pull_request_review_comment` as `created` and
`pull_request_review` as `submitted` for a new request, and that a
dismissal
  replays the dismissed review's body.
- That the REST API carries no reactions endpoint for a pull request
review.
- That `fromJSON` over a non-JSON input fails the expression rather than
  evaluating false, and that GitHub evaluates both operands of `||`.
- Every semantic `gha.py` models. A case here can only be as right as
that model.
- That a step with an explicit `if:` still requires the steps before it
to have
succeeded, which is what makes the refusal skip the identity mint. The
file's
  own comments already rest on this.

Nothing in this branch has been exercised by a real event on a runner.

## Case table

`Guard the request`: 241 assertions — 95 runs of an extracted step
script, and 71
call sites evaluating a shipped condition, `env:` mapping or declared
input.
Recounted from the shipped file.

| Group | Assertions | What it holds |
|---|---|---|
| 0 | 1 | the expression model `gha.py` uses |
| 1 | 11 | which requests reach a runner, on all three comment events |
| 2 | 14 | `allowed-bots` in the job condition, malformed and unset |
| 3 | 5 | the events the workflow does not handle |
| 4 | 8 | the review job's condition |
| 5 | 17 | the refusal, by event |
| 6 | 28 | the parse: which body is a command, and what it resolves to |
| 7 | 18 | a caller's own trigger phrase, including regex metacharacters
|
| 8 | 20 | who may ask, on every comment path |
| 9 | 20 | a bot held to `allowed-bots` |
| 10 | 17 | fork, label and once-per-PR, on the new paths |
| 11 | 11 | draft, first review, re-review and teardown |
| 12 | 16 | the payload field each step reads, per event |
| 12b | 5 | which guard output each consumer reads |
| 13 | 5 | the defaults a caller inherits |
| 14 | 5 | the acknowledgement's collection |
| 15 | 7 | the answer's collection |
| 16 | 33 | what `ai-assistant.yml` claims of the same body, on each
event |

`The reaction steps`: 77 + 77. `reactions.sh` carries 15 assertions over
four
cases for the collection each of the three steps reaches;
`conditions.py` gains 1
for the guard's new refusal step, which its file-wide sweep picks up on
its own.

`Place findings and resolve threads`: 271, untouched.

PLT-1147 PLT-1153 PLT-1161 PLT-1164

🤖 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