Skip to content

Commit fdd449d

Browse files
authored
Merge pull request #89 from d-morrison/fix/dedup-absorbed-comment-runs
Dedup the queued run a late @claude comment spawns after it's absorbed
2 parents 67465e2 + 7ec2808 commit fdd449d

1 file changed

Lines changed: 109 additions & 13 deletions

File tree

.github/workflows/claude.yml

Lines changed: 109 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,22 @@
1414
# replies (questions/reviews) that agent mode would otherwise discard.
1515
# - "Dispatch claude-code-review.yml on @claude review" routes review
1616
# requests to the dedicated reviewer instead of self-reviewing.
17+
# - "Skip if this comment was already handled by an active run" dedups a
18+
# late comment that a still-running session already absorbed (see below).
1719
# Ported from d-morrison/rme (#788/#794/#805); adapted to qwt's lighter
1820
# setup (DESCRIPTION-based deps, no renv; no submodule-token URL rewrite).
1921
#
2022
# The `prompt:` also has Claude poll for late-arriving @claude comments
2123
# (issue/PR comment + PR review endpoints) before declaring done, so a
2224
# follow-up posted mid-run is handled in-session rather than dropped
23-
# (issue #73). The concurrency block serializes runs, but a queued run can
24-
# still re-handle a comment the active run already absorbed.
25+
# (issue #73). The concurrency block serializes runs, so a comment posted
26+
# while a session is active gets absorbed by that session via polling — but
27+
# that comment ALSO queued its own run. To stop the queued run from
28+
# re-handling it, the session marks each absorbed comment with a
29+
# github-actions 🚀 reaction, and the "Skip if this comment was already
30+
# handled" step makes a run bail when its triggering comment already carries
31+
# that marker. (Review-submission triggers have no reactions endpoint, so
32+
# that one case can still double up; it degrades to the pre-dedup behavior.)
2533
#
2634
# Project guidance for Claude lives in CLAUDE.md at the repo root.
2735

@@ -75,12 +83,60 @@ jobs:
7583
# routing silently fails)
7684

7785
steps:
86+
# Dedup: a late @claude comment that an already-running session
87+
# absorbed (via the polling step in the prompt) still spawned its own
88+
# run, now queued behind the active one by the concurrency group. The
89+
# active session marks each comment it absorbs with a github-actions
90+
# 🚀 reaction; this step makes the queued run skip itself when its
91+
# triggering comment already carries that marker, so the comment isn't
92+
# handled twice. Every later step is gated on `skip != 'true'`.
93+
#
94+
# Only comment events can carry a reaction, so this runs for
95+
# `issue_comment` / `pull_request_review_comment` only; for
96+
# `issues:opened` and `pull_request_review` the step is skipped, the
97+
# output is empty (`!= 'true'`), and the run proceeds normally.
98+
#
99+
# The marker must be from github-actions[bot] — only this repo's
100+
# GITHUB_TOKEN can author that reaction, so a random user can't
101+
# suppress a run by hand-reacting 🚀. On any API error we default to
102+
# NOT skipping (fail open), since a wrongful skip would drop the
103+
# request — the exact failure mode issue #73 exists to prevent.
104+
- name: Skip if this comment was already handled by an active run
105+
id: dedup
106+
# Fail open: a shell-level error here must not fail the job (that
107+
# would block the run instead of degrading to the pre-dedup
108+
# duplicate-run behavior). The `|| echo 0` handles gh-api errors;
109+
# this covers anything else.
110+
continue-on-error: true
111+
if: github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment'
112+
env:
113+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
114+
EVENT_NAME: ${{ github.event_name }}
115+
COMMENT_ID: ${{ github.event.comment.id }}
116+
REPO: ${{ github.repository }}
117+
run: |
118+
if [ "$EVENT_NAME" = "pull_request_review_comment" ]; then
119+
ENDPOINT="repos/$REPO/pulls/comments/$COMMENT_ID/reactions"
120+
else
121+
ENDPOINT="repos/$REPO/issues/comments/$COMMENT_ID/reactions"
122+
fi
123+
MARKED=$(gh api "$ENDPOINT" \
124+
--jq '[.[] | select(.content == "rocket" and .user.login == "github-actions[bot]")] | length' \
125+
2>/dev/null || echo 0)
126+
if [ "$MARKED" != "0" ]; then
127+
echo "Comment $COMMENT_ID already carries the 🚀 handled-marker; skipping this duplicate run."
128+
echo "skip=true" >> "$GITHUB_OUTPUT"
129+
else
130+
echo "skip=false" >> "$GITHUB_OUTPUT"
131+
fi
132+
78133
# Post a visible acknowledgment BEFORE the multi-minute R/Quarto
79134
# setup begins, so the user knows the @claude mention was received.
80135
# `continue-on-error` keeps a transient comment-API hiccup from
81136
# failing the whole run. Posted by github-actions[bot], so it does
82137
# not re-trigger this workflow (the if: gate keys on `@claude`).
83138
- name: Acknowledge @claude mention
139+
if: steps.dedup.outputs.skip != 'true'
84140
continue-on-error: true
85141
env:
86142
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -91,23 +147,27 @@ jobs:
91147
--body ":eyes: Picked up by [workflow run #${{ github.run_id }}]($RUN_URL). R/Quarto setup runs first; Claude itself responds after that."
92148
93149
- name: Checkout repository
150+
if: steps.dedup.outputs.skip != 'true'
94151
uses: actions/checkout@v4
95152
with:
96153
fetch-depth: 1
97154
submodules: recursive
98155

99156
- name: Set up Quarto
157+
if: steps.dedup.outputs.skip != 'true'
100158
uses: quarto-dev/quarto-actions/setup@v2
101159
with:
102160
tinytex: true
103161
env:
104162
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
105163

106164
- uses: r-lib/actions/setup-r@v2
165+
if: steps.dedup.outputs.skip != 'true'
107166
with:
108167
use-public-rspm: true
109168

110169
- uses: r-lib/actions/setup-r-dependencies@v2
170+
if: steps.dedup.outputs.skip != 'true'
111171
with:
112172
packages: |
113173
any::knitr
@@ -118,7 +178,9 @@ jobs:
118178
# the post-steps can tell whether Claude pushed new commits.
119179
- name: Capture PR head SHA before Claude
120180
id: head_before
121-
if: github.event.pull_request.number || github.event.issue.pull_request
181+
if: |
182+
steps.dedup.outputs.skip != 'true' &&
183+
(github.event.pull_request.number || github.event.issue.pull_request)
122184
env:
123185
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
124186
run: |
@@ -136,8 +198,11 @@ jobs:
136198
- name: Set up branch for issue trigger
137199
id: issue_branch
138200
if: |
139-
github.event_name == 'issues' ||
140-
(github.event_name == 'issue_comment' && !github.event.issue.pull_request)
201+
steps.dedup.outputs.skip != 'true' &&
202+
(
203+
github.event_name == 'issues' ||
204+
(github.event_name == 'issue_comment' && !github.event.issue.pull_request)
205+
)
141206
env:
142207
ISSUE_NUMBER: ${{ github.event.issue.number }}
143208
run: |
@@ -149,6 +214,7 @@ jobs:
149214
150215
- name: Run Claude Code
151216
id: claude
217+
if: steps.dedup.outputs.skip != 'true'
152218
uses: anthropics/claude-code-action@v1
153219
# GH_TOKEN authenticates the `gh` commands granted in claude_args
154220
# (gh pr create/edit/view); without it `gh` prompts and fails in CI.
@@ -240,6 +306,23 @@ jobs:
240306
with comments still arriving, note it in your final message
241307
and stop; the next queued run picks up from there.
242308
309+
4. Every late comment you address in step 3 also queued its OWN
310+
run of this workflow (one fires per @claude comment). To stop
311+
that duplicate run from re-handling what you just did, mark
312+
each absorbed comment with a 🚀 reaction — a pre-step in the
313+
duplicate run sees the marker and skips itself. Use the `id`
314+
from the polling response, and the endpoint matching where the
315+
comment came from:
316+
```
317+
gh api repos/${{ github.repository }}/issues/comments/<id>/reactions -f content=rocket # a /issues/N/comments entry
318+
gh api repos/${{ github.repository }}/pulls/comments/<id>/reactions -f content=rocket # a /pulls/N/comments entry
319+
```
320+
(Do NOT mark the comment/review that originally triggered YOU
321+
— only the late ones you picked up by polling. Review
322+
submissions from `/pulls/N/reviews` have no reactions
323+
endpoint, so leave those unmarked; their duplicate run just
324+
proceeds as before.)
325+
243326
# Allowed git/gh/quarto/Rscript commands. `git push` is narrowed to
244327
# `origin` and destructive flag/refspec variants are denied so the
245328
# write-scoped token can't rewrite or delete refs. `Rscript -e` is
@@ -260,12 +343,15 @@ jobs:
260343
# Because the prefix is anchored at `gh api repos/`, the flag-first
261344
# write form (`gh api -X POST repos/...`) does NOT match this allow
262345
# rule and is denied. The two `gh api -X` / `--method` entries in
263-
# disallowedTools below also deny that form explicitly. What this
264-
# still can't pattern-block is a URL-first write that appends a
265-
# method/field flag AFTER the URL (`gh api repos/<repo>/... -X
266-
# POST`), since allowed-tools can't filter by HTTP method or by
267-
# flags past the prefix — there, the real bound is the
268-
# GITHUB_TOKEN scopes plus the trusted-author gate in the job `if:`.
346+
# disallowedTools below also deny that form explicitly. The
347+
# URL-first write form (`gh api repos/<repo>/... -f field=val`,
348+
# which `gh` sends as POST) DOES match and is intentionally relied
349+
# on by the dedup marker in the prompt above (`gh api
350+
# repos/<repo>/issues|pulls/comments/<id>/reactions -f
351+
# content=rocket`). Allowed-tools can't filter by HTTP method or by
352+
# flags past the prefix, so any other URL-first write to this repo
353+
# is reachable too — the real bound there is the GITHUB_TOKEN scopes
354+
# plus the trusted-author gate in the job `if:` above.
269355
claude_args: |
270356
--allowedTools "Bash(Rscript -e 'lintr::lint*'),Bash(Rscript -e 'devtools::check*'),Bash(Rscript -e 'devtools::document*'),Bash(Rscript -e 'pkgdown::build*'),Bash(quarto render:*),Bash(quarto check:*),Bash(git diff:*),Bash(git log:*),Bash(git status:*),Bash(git show:*),Bash(git checkout:*),Bash(git switch:*),Bash(git branch:*),Bash(git add:*),Bash(git commit:*),Bash(git push origin:*),Bash(git push -u origin:*),Bash(gh api repos/${{ github.repository }}/:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr create:*),Bash(gh pr edit:*),Bash(gh issue view:*)" --disallowedTools "Bash(git push --force:*),Bash(git push -f:*),Bash(git push --delete:*),Bash(git push -d:*),Bash(git push --mirror:*),Bash(git push --tags:*),Bash(git push --all:*),Bash(git push origin +*),Bash(git push -u origin +*),Bash(gh api -X:*),Bash(gh api --method:*)"
271357
@@ -274,7 +360,10 @@ jobs:
274360
# step). PR-context only; mirrors the before-step above.
275361
- name: Capture PR head SHA after Claude
276362
id: head_after
277-
if: always() && (github.event.pull_request.number || github.event.issue.pull_request)
363+
if: |
364+
always() &&
365+
steps.dedup.outputs.skip != 'true' &&
366+
(github.event.pull_request.number || github.event.issue.pull_request)
278367
env:
279368
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
280369
run: |
@@ -291,6 +380,7 @@ jobs:
291380
- name: Post Claude's response if no code was committed
292381
if: |
293382
always() &&
383+
steps.dedup.outputs.skip != 'true' &&
294384
steps.claude.outcome == 'success' &&
295385
!(
296386
(
@@ -390,6 +480,7 @@ jobs:
390480
- name: Dispatch claude-code-review.yml on @claude review comment
391481
if: |
392482
always() &&
483+
steps.dedup.outputs.skip != 'true' &&
393484
(github.event.pull_request.number || github.event.issue.pull_request) &&
394485
(
395486
contains(github.event.comment.body, '@claude review') ||
@@ -408,7 +499,11 @@ jobs:
408499
# human reviewer via the requested_reviewers API — qwt has no
409500
# standing-reviewer convention; add that here if one is adopted.)
410501
- name: Dispatch code review if Claude pushed commits
411-
if: always() && (github.event.pull_request.number || github.event.issue.pull_request) && steps.head_before.outputs.sha != ''
502+
if: |
503+
always() &&
504+
steps.dedup.outputs.skip != 'true' &&
505+
(github.event.pull_request.number || github.event.issue.pull_request) &&
506+
steps.head_before.outputs.sha != ''
412507
env:
413508
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
414509
SHA_BEFORE: ${{ steps.head_before.outputs.sha }}
@@ -432,6 +527,7 @@ jobs:
432527
- name: Push branch and open draft PR for issue trigger
433528
if: |
434529
always() &&
530+
steps.dedup.outputs.skip != 'true' &&
435531
steps.issue_branch.outputs.branch != ''
436532
env:
437533
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

0 commit comments

Comments
 (0)