From 671001e5edfbbd2ff874e9cd320276172c2b12fa Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 10:01:50 +0100 Subject: [PATCH 1/3] fix: delete the progress comment once real output exists, dedupe reposts Every review/triage/interactive run always leaves its own durable output behind on success (a submitted review, a triage comment, an interactive reply), so the generic "Claude is working on this" ping has nothing left to say once that lands -- delete it outright instead of editing it to "finished", so repeat runs on the same pull request or issue stop leaving a trail of near-identical status comments. On a run that does not succeed, the comment still carries a hidden marker so the next run's "Post progress comment" step can find and reuse it instead of posting another one, keeping a string of failed runs down to a single comment too. --- action.yml | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/action.yml b/action.yml index 82ce348..47e10ed 100644 --- a/action.yml +++ b/action.yml @@ -565,7 +565,7 @@ runs: echo "credential_count=${COUNT}" >>"$GITHUB_OUTPUT" - # --- Progress comment --- A plain, ordinary issue comment posted and later edited entirely by this action's own steps via `gh api`, independent of track_progress/use_sticky_comment above: those are pure passthroughs to upstream's own tracking-comment mechanism, which only its tag mode implements, and every mode this action runs (review, triage, interactive) always sets `prompt` (see "Compose prompt" below) -- upstream's detectMode treats a non-empty prompt as agent mode, which has no tracking-comment support at all. Placed first, before stack detection or prompt composition, so the comment lands as early as possible rather than waiting on work this comment doesn't depend on. + # --- Progress comment --- A plain, ordinary issue comment posted and later deleted (on success) or edited (on failure) entirely by this action's own steps via `gh api`, independent of track_progress/use_sticky_comment above: those are pure passthroughs to upstream's own tracking-comment mechanism, which only its tag mode implements, and every mode this action runs (review, triage, interactive) always sets `prompt` (see "Compose prompt" below) -- upstream's detectMode treats a non-empty prompt as agent mode, which has no tracking-comment support at all. Placed first, before stack detection or prompt composition, so the comment lands as early as possible rather than waiting on work this comment doesn't depend on. - name: Post progress comment id: progress-comment if: ${{ inputs.post_progress_comment == 'true' }} @@ -598,10 +598,20 @@ runs: *) VERB="working on this" ;; esac + # "Update progress comment" below deletes this comment outright once the run succeeds -- every mode always leaves its own real output behind on success (a submitted review, a triage comment, an interactive reply), so nothing is lost. A marker-carrying comment found here can therefore only be a leftover from a run that did NOT succeed (its own "Update progress comment" left it in place instead of deleting it). Reusing that one instead of posting a new one is what stops a string of failed runs on the same pull request/issue from piling up "did not complete" comments one per run. + MARKER="" + BODY=$(printf 'šŸ”„ Claude is %s… [View job run](%s)\n\n%s' "$VERB" "$RUN_URL" "$MARKER") + EXISTING_ID=$(gh api "repos/${REPOSITORY}/issues/${ENTITY_NUMBER}/comments" --paginate -q 'map(select(.body | contains(""))) | (.[-1].id // empty)' 2>/dev/null || echo "") + # Three attempts with a short backoff before falling back on continue-on-error above -- a transient rate limit or network blip shouldn't cost the comment even though a sustained failure (bad permissions, a real outage) still must not fail the job. COMMENT_ID="" for attempt in 1 2 3; do - if COMMENT_ID=$(gh api "repos/${REPOSITORY}/issues/${ENTITY_NUMBER}/comments" -f body="šŸ”„ Claude is ${VERB}… [View job run](${RUN_URL})" -q .id); then + if [ -n "$EXISTING_ID" ]; then + if gh api -X PATCH "repos/${REPOSITORY}/issues/comments/${EXISTING_ID}" -f body="$BODY"; then + COMMENT_ID="$EXISTING_ID" + break + fi + elif COMMENT_ID=$(gh api "repos/${REPOSITORY}/issues/${ENTITY_NUMBER}/comments" -f body="$BODY" -q .id); then break fi COMMENT_ID="" @@ -1621,7 +1631,7 @@ runs: path: ~/.claude/projects key: ${{ steps.cache-scope.outputs.cache_key }} - # Counterpart to "Post progress comment" above -- edits the SAME comment (by its captured id) once the whole run has finished, including any optional wrap-up/fix/summary pass above, so the placeholder never sits stale as "in progress" once real output exists. if: always() so it still fires when Run Claude Code itself failed outright -- exactly when a plain visible status marker matters most, since nothing else in this composite action guarantees any comment lands when a mode's own model-driven output (a review, a triage comment, an interactive reply) never got produced. + # Counterpart to "Post progress comment" above -- resolves the SAME comment (by its captured id) once the whole run has finished, including any optional wrap-up/fix/summary pass above. if: always() so it still fires when Run Claude Code itself failed outright -- exactly when a plain visible status marker matters most, since nothing else in this composite action guarantees any comment lands when a mode's own model-driven output (a review, a triage comment, an interactive reply) never got produced. - name: Update progress comment if: ${{ always() && inputs.post_progress_comment == 'true' && steps.progress-comment.outputs.comment_id != '' }} # Same reasoning as "Post progress comment" above: this is the composite action's own final step, and a transient gh api failure here must not surface as this whole action having failed when the actual mode work (review/triage/interactive) already completed beforehand. @@ -1641,12 +1651,23 @@ runs: if [ "$OUTCOME" = "success" ] && [ -n "${WRAP_UP_OUTCOME:-}" ] && [ "$WRAP_UP_OUTCOME" != "success" ] && [ "$WRAP_UP_OUTCOME" != "skipped" ]; then OUTCOME="$WRAP_UP_OUTCOME" fi + + # Real output now exists elsewhere (a submitted review, a triage comment, an interactive reply -- every mode always leaves one on success, per each mode's own prompt), so this placeholder has nothing left to say. Deleting it outright, rather than editing it to a "finished" message, is what stops repeat runs on the same pull request/issue from leaving a trail of near-identical status pings behind. if [ "$OUTCOME" = "success" ]; then - BODY="āœ… Claude finished — see below for the result. [View job run](${RUN_URL})" - else - BODY="āŒ Claude's run did not complete (outcome: ${OUTCOME}). [View job run](${RUN_URL})" + for attempt in 1 2 3; do + if gh api -X DELETE "repos/${REPOSITORY}/issues/comments/${COMMENT_ID}"; then + echo "Deleted progress comment ${COMMENT_ID} (outcome: success)." + exit 0 + fi + echo "Failed to delete progress comment (attempt ${attempt}/3)." + [ "$attempt" -lt 3 ] && sleep 2 + done + echo "Giving up on deleting the progress comment after 3 attempts." + exit 1 fi - # Same three-attempt backoff as "Post progress comment" above. + + # Non-success: no other durable output is guaranteed to exist, so this comment stays as the one visible signal something went wrong -- left in place, still carrying the marker, for the next run's "Post progress comment" to find and reuse instead of piling up another one. + BODY=$(printf "āŒ Claude's run did not complete (outcome: %s). [View job run](%s)\n\n" "$OUTCOME" "$RUN_URL") for attempt in 1 2 3; do if gh api -X PATCH "repos/${REPOSITORY}/issues/comments/${COMMENT_ID}" -f body="$BODY"; then echo "Updated progress comment ${COMMENT_ID} (outcome: ${OUTCOME})." From f76bcd780c788d398aef9f0619559e7817aeab65 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 10:18:03 +0100 Subject: [PATCH 2/3] fix: only reuse a terminal-failed progress comment, never a pending one Interactive mode has no concurrency group, so two @claude mentions close together can trigger overlapping runs. The prior marker-based reuse matched any progress comment regardless of state, so a run's "Post progress comment" step could adopt a still-in-flight sibling's in-progress comment -- letting that sibling's later delete-on-success silently erase the adopting run's own eventual failure message. Splits the marker into a pending state (set on creation, never eligible for reuse) and a failed state (set only once a run's own "Update progress comment" step has actually resolved to failure). Reuse now searches for the failed marker only, so a comment is never adopted until the run that owns it has genuinely finished. Also scopes the search to bot-authored comments, narrowing an incidental match against unrelated human prose that happens to quote the marker text. --- action.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/action.yml b/action.yml index 47e10ed..4783143 100644 --- a/action.yml +++ b/action.yml @@ -598,10 +598,11 @@ runs: *) VERB="working on this" ;; esac - # "Update progress comment" below deletes this comment outright once the run succeeds -- every mode always leaves its own real output behind on success (a submitted review, a triage comment, an interactive reply), so nothing is lost. A marker-carrying comment found here can therefore only be a leftover from a run that did NOT succeed (its own "Update progress comment" left it in place instead of deleting it). Reusing that one instead of posting a new one is what stops a string of failed runs on the same pull request/issue from piling up "did not complete" comments one per run. - MARKER="" - BODY=$(printf 'šŸ”„ Claude is %s… [View job run](%s)\n\n%s' "$VERB" "$RUN_URL" "$MARKER") - EXISTING_ID=$(gh api "repos/${REPOSITORY}/issues/${ENTITY_NUMBER}/comments" --paginate -q 'map(select(.body | contains(""))) | (.[-1].id // empty)' 2>/dev/null || echo "") + # "Update progress comment" below deletes this comment outright once the run succeeds -- every mode always leaves its own real output behind on success (a submitted review, a triage comment, an interactive reply), so nothing is lost -- and on failure marks it with the FAILED marker instead, the terminal state below searches for. Only a FAILED-marked comment is eligible for reuse, never a PENDING one: interactive mode has no concurrency group, so two overlapping runs on the same pull request/issue are possible, and a PENDING comment may belong to a sibling run that is still in flight and has not resolved its own comment yet -- adopting it here would let this run's later delete-on-success silently erase that sibling's still-pending outcome out from under it. A FAILED comment, by contrast, can only exist once its own run has already reached "Update progress comment" and finished, so it is safe to take over. + MARKER_PENDING="" + MARKER_FAILED="" + BODY=$(printf 'šŸ”„ Claude is %s… [View job run](%s)\n\n%s' "$VERB" "$RUN_URL" "$MARKER_PENDING") + EXISTING_ID=$(gh api "repos/${REPOSITORY}/issues/${ENTITY_NUMBER}/comments" --paginate -q 'map(select(.user.type == "Bot" and (.body | contains("")))) | (.[-1].id // empty)' 2>/dev/null || echo "") # Three attempts with a short backoff before falling back on continue-on-error above -- a transient rate limit or network blip shouldn't cost the comment even though a sustained failure (bad permissions, a real outage) still must not fail the job. COMMENT_ID="" @@ -1666,8 +1667,8 @@ runs: exit 1 fi - # Non-success: no other durable output is guaranteed to exist, so this comment stays as the one visible signal something went wrong -- left in place, still carrying the marker, for the next run's "Post progress comment" to find and reuse instead of piling up another one. - BODY=$(printf "āŒ Claude's run did not complete (outcome: %s). [View job run](%s)\n\n" "$OUTCOME" "$RUN_URL") + # Non-success: no other durable output is guaranteed to exist, so this comment stays as the one visible signal something went wrong -- left in place, now carrying the FAILED marker (not the PENDING one it started with) so a later, non-concurrent run's "Post progress comment" can find and reuse it instead of piling up another one, without a still-running sibling ever mistaking it for a leftover to adopt. + BODY=$(printf "āŒ Claude's run did not complete (outcome: %s). [View job run](%s)\n\n" "$OUTCOME" "$RUN_URL") for attempt in 1 2 3; do if gh api -X PATCH "repos/${REPOSITORY}/issues/comments/${COMMENT_ID}" -f body="$BODY"; then echo "Updated progress comment ${COMMENT_ID} (outcome: ${OUTCOME})." From e780b4242c7beab64229ab15ae88cb3b432a8258 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 10:27:45 +0100 Subject: [PATCH 3/3] fix: never adopt an existing progress comment, only ever delete stale ones The prior lookup-then-patch reuse was still a check-then-act race, just on a narrower trigger: two runs starting close together (interactive mode has no concurrency group) could both find the same stale failed comment before either patched it, both take ownership of it, and the one that finishes last silently lose its own outcome to a 404 once the other has deleted or reused that same comment. Replaces adoption with pure cleanup: delete every terminal-FAILED progress comment this bot left behind, then always create a fresh comment this run alone owns for its own lifetime. Deleting is race-free regardless of how many runs attempt it concurrently -- a delete that loses to a sibling's own delete of the same comment just 404s, a harmless no-op -- and no run's own delete-on-success or patch-to-failed can ever land on a comment another run still considers its own. Also threads the failed-state marker through as a step output instead of duplicating the same literal in both steps by hand. --- action.yml | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/action.yml b/action.yml index 4783143..48f70af 100644 --- a/action.yml +++ b/action.yml @@ -598,21 +598,20 @@ runs: *) VERB="working on this" ;; esac - # "Update progress comment" below deletes this comment outright once the run succeeds -- every mode always leaves its own real output behind on success (a submitted review, a triage comment, an interactive reply), so nothing is lost -- and on failure marks it with the FAILED marker instead, the terminal state below searches for. Only a FAILED-marked comment is eligible for reuse, never a PENDING one: interactive mode has no concurrency group, so two overlapping runs on the same pull request/issue are possible, and a PENDING comment may belong to a sibling run that is still in flight and has not resolved its own comment yet -- adopting it here would let this run's later delete-on-success silently erase that sibling's still-pending outcome out from under it. A FAILED comment, by contrast, can only exist once its own run has already reached "Update progress comment" and finished, so it is safe to take over. - MARKER_PENDING="" + # Delete every terminal-FAILED progress comment this bot left behind on a now-finished previous run, then always create a fresh comment of this run's own -- never adopt an existing one's identity by editing it in place. Adopting via lookup-then-patch is a check-then-act race: interactive mode has no concurrency group, so two overlapping runs (or a stale failed comment plus a fresh run starting moments apart) can both find the same candidate before either has claimed it, both patch it, and both believe they own it -- whichever finishes last then loses its own outcome to a 404 when the comment underneath it has already been deleted or reused by the other. Deleting has no such race: a delete that loses to a sibling's own delete of the same comment just 404s, a harmless no-op, and creating always mints a comment only this run's own id will ever point at, so its own eventual delete-on-success or patch-to-failed below can never step on another run's. MARKER_FAILED="" - BODY=$(printf 'šŸ”„ Claude is %s… [View job run](%s)\n\n%s' "$VERB" "$RUN_URL" "$MARKER_PENDING") - EXISTING_ID=$(gh api "repos/${REPOSITORY}/issues/${ENTITY_NUMBER}/comments" --paginate -q 'map(select(.user.type == "Bot" and (.body | contains("")))) | (.[-1].id // empty)' 2>/dev/null || echo "") + JQ_STALE_FILTER='map(select(.user.type == "Bot" and (.body | contains("'"$MARKER_FAILED"'")))) | .[].id' + STALE_IDS=$(gh api "repos/${REPOSITORY}/issues/${ENTITY_NUMBER}/comments" --paginate -q "$JQ_STALE_FILTER" 2>/dev/null || echo "") + for id in $STALE_IDS; do + gh api -X DELETE "repos/${REPOSITORY}/issues/comments/${id}" >/dev/null 2>&1 || true + done + + BODY=$(printf 'šŸ”„ Claude is %s… [View job run](%s)' "$VERB" "$RUN_URL") # Three attempts with a short backoff before falling back on continue-on-error above -- a transient rate limit or network blip shouldn't cost the comment even though a sustained failure (bad permissions, a real outage) still must not fail the job. COMMENT_ID="" for attempt in 1 2 3; do - if [ -n "$EXISTING_ID" ]; then - if gh api -X PATCH "repos/${REPOSITORY}/issues/comments/${EXISTING_ID}" -f body="$BODY"; then - COMMENT_ID="$EXISTING_ID" - break - fi - elif COMMENT_ID=$(gh api "repos/${REPOSITORY}/issues/${ENTITY_NUMBER}/comments" -f body="$BODY" -q .id); then + if COMMENT_ID=$(gh api "repos/${REPOSITORY}/issues/${ENTITY_NUMBER}/comments" -f body="$BODY" -q .id); then break fi COMMENT_ID="" @@ -624,6 +623,8 @@ runs: exit 1 fi echo "comment_id=${COMMENT_ID}" >> "$GITHUB_OUTPUT" + # Threaded through to "Update progress comment" below as the single source of truth for the marker text, rather than a second hardcoded literal that has to be kept in sync by hand. + echo "marker_failed=${MARKER_FAILED}" >> "$GITHUB_OUTPUT" echo "Posted progress comment ${COMMENT_ID}." - name: Detect stack @@ -1642,6 +1643,7 @@ runs: GH_TOKEN: ${{ inputs.github_token || github.token }} REPOSITORY: ${{ github.repository }} COMMENT_ID: ${{ steps.progress-comment.outputs.comment_id }} + MARKER_FAILED: ${{ steps.progress-comment.outputs.marker_failed }} CLAUDE_OUTCOME: ${{ steps.claude.outcome }} # "Wrap up on turn limit" is the one later step with no continue-on-error and no prior id (unlike the fix pass / structured-summary steps, which are all continue-on-error: true and so can never make an already-successful primary run look failed here) -- if it runs and itself fails, the job outcome is failure even though steps.claude.outcome is still the earlier, successful "success". Folded in below rather than trusted on its own, since it reports "skipped" (not "success") whenever detect-turn-limit didn't fire, which must not be read as a failure. WRAP_UP_OUTCOME: ${{ steps.wrap-up.outcome }} @@ -1667,8 +1669,8 @@ runs: exit 1 fi - # Non-success: no other durable output is guaranteed to exist, so this comment stays as the one visible signal something went wrong -- left in place, now carrying the FAILED marker (not the PENDING one it started with) so a later, non-concurrent run's "Post progress comment" can find and reuse it instead of piling up another one, without a still-running sibling ever mistaking it for a leftover to adopt. - BODY=$(printf "āŒ Claude's run did not complete (outcome: %s). [View job run](%s)\n\n" "$OUTCOME" "$RUN_URL") + # Non-success: no other durable output is guaranteed to exist, so this comment stays as the one visible signal something went wrong -- left in place, now carrying the FAILED marker (threaded through from "Post progress comment" above as the single source of truth for its exact text) so a later run's own "Post progress comment" step deletes it during its own cleanup pass instead of it piling up indefinitely. + BODY=$(printf 'āŒ Claude'"'"'s run did not complete (outcome: %s). [View job run](%s)\n\n%s' "$OUTCOME" "$RUN_URL" "$MARKER_FAILED") for attempt in 1 2 3; do if gh api -X PATCH "repos/${REPOSITORY}/issues/comments/${COMMENT_ID}" -f body="$BODY"; then echo "Updated progress comment ${COMMENT_ID} (outcome: ${OUTCOME})."