Skip to content

Reliability hardening: session circuit breaker, timeouts, retry, dedup - #20

Merged
don-petry merged 3 commits into
mainfrom
claude/practical-cannon-f1a4d4
Apr 29, 2026
Merged

don-petry merged 3 commits into
mainfrom
claude/practical-cannon-f1a4d4

Conversation

@don-petry

Copy link
Copy Markdown
Collaborator

Summary

Reliability and stability hardening for the PR review agent, motivated by an audit that surfaced multiple silent-failure modes — the agent could burn an entire candidate pool without producing a review while the workflow looked green, and was stacking duplicate reviews on the same PR (10 stacked APPROVED reviews observed on petry-projects/ContentTwin#100).

Session-level error handling

  • Session circuit breaker in .github/workflows/pr-review.yml: any non-zero / non-100 exit from review-one-pr.sh (general failure or rate-limit on the fallback engine) breaks the per-PR loop, logs ::error::Session aborted early... with the failing PR / reason / count of skipped candidates, and exits the step with code 1 so the run shows red.
  • Triage non-JSON now hard-fails (scripts/review-one-pr.sh): replaces the silent fallback that synthesized a fake escalate=MEDIUM verdict and proceeded to the deep tier — masking a tier-1 model regression behind a successful tier-2 review and silently doubling cost on every broken triage.

Per-tier timeouts and bounded retry

  • Per-tier timeout wrappers in scripts/engine.sh for triage / deep / audit / action / duck (180/600/600/300/300s defaults, env-overridable). Previously only the duck had a timeout — a hung tier could burn the whole 60min job budget.
  • Retry-with-backoff on transient errors (124 / 137 / 143 only) applied to run_triage where stdout is captured by $(...) so retries are safe. Deliberately not applied to run_agentic / run_duck — callers redirect stdout to a file and a retry would corrupt the partial first-attempt output. Those tiers fall through to the session circuit breaker on transient failure.

Stop stacking duplicate reviews on the same PR

Two distinct bugs were causing it; both fixed:

  • Bug A — order-dependent idempotency check in review-one-pr.sh. The previous marker-discovery did ((.reviews // []) + (.comments // [])) | tail -1, which depends on array order, not chronological order. When old comments with markers existed alongside newer reviews with markers, tail -1 returned a stale SHA → the script thought the head was un-reviewed and re-ran every hour. Replaced with a single jq pipeline that tags each item with submittedAt / createdAt, sorts by timestamp, and takes the actual most-recent marker.
  • Bug B — no cleanup of prior agent items in post-pr-review.sh. Added mark_prior_agent_items_obsolete, called after every successful post:
    • Prior APPROVED / COMMENTED / CHANGES_REQUESTED agent reviews → dismissed via the GitHub dismissal API (UI shows "Dismissed" with strikethrough).
    • Prior agent issue comments → edited to wrap body in <details><summary>Superseded by re-review at &lt;SHA&gt;</summary>...</details>, with a <!-- pr-review-agent superseded --> sentinel preventing recursive nesting.
    • All API calls best-effort (|| true) so cleanup failures don't break the workflow.

Test plan

  • YAML + bash + shellcheck clean on all touched files
  • Test workflow run dispatched on this branch (run 25119252009) — the new error handling fired exactly as designed:
  • Bug A jq pipeline verified against live data on ContentTwin#100 — correctly returns the actual newest marker SHA 3af8c8ee instead of stale cd9132d6
  • Bug B preview against live data on ContentTwin#100 — correctly identifies the 9 stale agent reviews and 2 stale agent comments that would be cleaned on the next legitimate re-review
  • Watch next few hourly runs after merge to confirm no regressions in the happy path
  • Confirm that, on the next head-SHA change for a PR with prior agent reviews, the cleanup correctly dismisses old reviews and collapses old comments

Follow-ups (separate PRs in flight)

  • #18 — wire up MAX_REVIEW_CYCLES enforcement (currently dead config)
  • #19 — fix the broken triage prompt that this PR's hard-fail surfaced

🤖 Generated with Claude Code

Copilot Bot review requested due to automatic review settings April 29, 2026 23:21
don-petry and others added 2 commits April 29, 2026 18:24
…ard-fail

Reliability hardening for the PR review agent.

1. Session circuit breaker (.github/workflows/pr-review.yml): on any
   non-zero, non-100 exit from review-one-pr.sh (general failure or rate
   limit on the fallback engine), break the per-PR loop, log a clear
   error annotation naming the failing PR and reason, and exit the step
   with code 1 so the run shows red. Prevents one systemic problem from
   silently burning the entire candidate pool.

2. Per-tier timeouts (scripts/engine.sh): triage/deep/audit/action/duck
   each get their own bounded timeout (180/600/600/300/300s defaults,
   env-overridable). Previously only the duck had a timeout — a hung
   tier could burn the whole 60min job budget.

3. Retry-with-backoff on transient errors (scripts/engine.sh): triage
   retries once on 124/137/143 (timeout / signal kill) since its caller
   captures stdout via $(...) so retries are safe. Deliberately NOT
   applied to run_agentic/run_duck where stdout is redirected to a file
   — a retry there would corrupt the partial first-attempt output.

4. Triage non-JSON now hard-fails (scripts/review-one-pr.sh): replaces
   the silent fallback that synthesized a fake "escalate=MEDIUM" verdict
   and proceeded to deep review. With the new circuit breaker, loud
   failure is the right call — masking a broken triage was burning
   tokens on every PR while the workflow looked healthy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two bugs were causing the agent to leave multiple comments on the same PR.
Together they produced 10 stacked APPROVED reviews on petry-projects/ContentTwin#100.

Bug A — idempotency check is order-dependent (review-one-pr.sh):
The previous marker-discovery code did:
  ((.reviews // []) + (.comments // [])) | .[].body | grep marker | tail -1
This relies on the array concatenation order, not chronological order. When
old agent comments existed alongside newer agent reviews, tail -1 picked
the comment-array marker (older) over the review-array marker (newer),
causing the script to think the head SHA hadn't been reviewed and re-run.
Replaced with a single jq pipeline that tags each item with submittedAt /
createdAt, sorts by timestamp, and takes the actual most-recent marker.

Bug B — no cleanup of prior agent items (post-pr-review.sh):
After successfully posting a new review/comment, prior agent items were
left in place, accumulating forever. Added mark_prior_agent_items_obsolete
which, after a successful post:
  - dismisses prior APPROVED/COMMENTED/CHANGES_REQUESTED agent reviews via
    the GitHub dismissal API (UI shows them struck-through as Dismissed)
  - edits prior agent comments to wrap their body in a collapsed <details>
    block with a "Superseded by re-review at <SHA>" summary, plus a
    `<!-- pr-review-agent superseded -->` sentinel for idempotency

All cleanup API calls are best-effort — failures don't break the workflow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@don-petry
don-petry force-pushed the claude/practical-cannon-f1a4d4 branch from 81a6c88 to c2204aa Compare April 29, 2026 23:24

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Reliability/stability hardening for the PR review agent by improving session-level failure behavior, adding per-tier execution bounds, and preventing duplicate/stacked reviews on the same PR.

Changes:

  • Make session runs fail-fast via a workflow “session circuit breaker” when a PR processing step fails or hits fallback-engine rate limits.
  • Add per-tier timeout wrappers and transient retry/backoff for triage invocations.
  • Fix idempotency marker detection ordering and add cleanup that dismisses/obsoletes prior agent reviews/comments after posting.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
scripts/review-one-pr.sh Fixes marker discovery to use timestamp sorting; hard-fails on triage exit/non-JSON to avoid silent escalation.
scripts/post-pr-review.sh Adds cleanup routine intended to dismiss prior agent reviews and collapse prior agent comments after posting.
scripts/engine.sh Introduces per-tier timeout env vars and transient retry loop for triage; wraps agentic tiers in timeout.
.github/workflows/pr-review.yml Adds session circuit breaker behavior so systemic failures stop the candidate loop and fail the run.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread scripts/post-pr-review.sh Outdated
Comment on lines +54 to +88
# Reviews: dismiss every prior agent review except the newest. State must
# be APPROVED, COMMENTED, or CHANGES_REQUESTED to be dismissable; DISMISSED
# ones are already handled, and PENDING ones aren't ours.
local stale_review_ids
stale_review_ids=$(echo "$reviews_json" | jq -r '
sort_by(.submitted_at)
| map(select(.body != null and (.body | test("<!-- pr-review-agent v1 sha=[a-f0-9]+"))))
| .[:-1]
| .[]
| select(.state == "APPROVED" or .state == "COMMENTED" or .state == "CHANGES_REQUESTED")
| .id
' 2>/dev/null || true)

if [ -n "$stale_review_ids" ]; then
while IFS= read -r review_id; do
[ -z "$review_id" ] && continue
echo " dismissing prior agent review $review_id (superseded by $PR_HEAD_SHA)"
gh api -X PUT "repos/$owner_repo/pulls/$pr_num/reviews/$review_id/dismissals" \
-f message="Superseded by automated re-review at $PR_HEAD_SHA." \
-f event=DISMISS >/dev/null 2>&1 || true
done <<< "$stale_review_ids"
fi

# Comments: edit each prior agent comment to wrap its body in a collapsed
# <details> block. The sentinel `<!-- pr-review-agent superseded -->`
# prevents re-wrapping on subsequent runs.
local stale_comments
stale_comments=$(echo "$comments_json" | jq -c '
sort_by(.created_at)
| map(select(.body != null and (.body | test("<!-- pr-review-agent v1 sha=[a-f0-9]+"))))
| .[:-1]
| .[]
| select(.body | test("<!-- pr-review-agent superseded -->") | not)
| {id: .id, body: .body}
' 2>/dev/null || true)

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

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

mark_prior_agent_items_obsolete is documented as preserving “the newest agent item by timestamp”, but the implementation preserves the newest review and newest comment independently (.[:-1] in each list). This can leave a stale agent comment uncollapsed after a new review is posted, or leave a stale agent review undismissed after a new fix-request comment is posted. Consider either (a) computing the newest agent item across reviews+comments by timestamp and obsoleting everything else, or (b) passing in what was just posted (review/comment id) and explicitly preserving only that item while obsoleting the rest.

Copilot uses AI. Check for mistakes.
Comment thread scripts/post-pr-review.sh Outdated
Comment on lines +72 to +73
-f message="Superseded by automated re-review at $PR_HEAD_SHA." \
-f event=DISMISS >/dev/null 2>&1 || true

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

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

The dismissal API call includes -f event=DISMISS when calling PUT repos/.../pulls/.../reviews/.../dismissals. That endpoint only accepts a dismissal message payload; sending an unsupported event field will likely cause the request to fail (and the stale reviews won’t be dismissed). Drop the event field and rely on the HTTP method/endpoint to perform the dismissal.

Suggested change
-f message="Superseded by automated re-review at $PR_HEAD_SHA." \
-f event=DISMISS >/dev/null 2>&1 || true
-f message="Superseded by automated re-review at $PR_HEAD_SHA." >/dev/null 2>&1 || true

Copilot uses AI. Check for mistakes.
Comment thread scripts/engine.sh
Comment on lines +144 to 148
# Per-tier timeout from DEEP_TIMEOUT_SEC (also applies to action/audit calls;
# they're all the same agentic shape and similarly priced).
run_agentic() {
local prompt_file="$1"
local model="$2"

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

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

AUDIT_TIMEOUT_SEC and ACTION_TIMEOUT_SEC are defined above, but run_agentic always uses timeout "$DEEP_TIMEOUT_SEC" regardless of whether the caller is running deep/action/audit. This makes the per-tier timeout env vars ineffective. Consider passing a timeout value into run_agentic (or adding tier-specific wrappers) and using the appropriate timeout at each call site.

Copilot uses AI. Check for mistakes.
…e JSON

Three fixes to mark_prior_agent_items_obsolete from the review of PR #20:

1. ::warning:: annotations on every cleanup API failure (review/comment
   list-fetch, individual review dismissal, individual comment fetch+edit).
   Previously these were silenced with `|| true`, so a permissions change
   on the dismissal endpoint would let duplicates stack indefinitely with
   no signal in the Actions UI. Cleanup is still non-fatal — the new post
   has already landed — but failures are now visible.

2. Preserve the globally-latest agent item across BOTH categories, not the
   newest of each category separately. The earlier code split reviews and
   comments and applied `[:-1]` to each, which left a stale fix-request
   comment in place when the new post was a review (or vice versa). The
   one-off cleanup of ContentTwin#100 hit exactly this case: 12 stacked
   reviews collapsed to 1, but a stale comment from 2026-04-25 (SHA
   cd9132d6) was preserved as "newest comment" even though the latest
   review at SHA 3af8c8ee was newer overall. Now: compute the max
   timestamp across both feeds, exclude items at that timestamp.

3. Stage API responses to disk (`mktemp` + `jq <file>`) instead of routing
   through `--argjson "$var"`. The old approach broke on rare unescaped
   control chars in user-authored comment bodies (jq refused to parse the
   resulting shell-vared JSON). File-based input sidesteps the shell
   pipeline entirely.

Co-Authored-By: Claude Opus 4.7 <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.

2 participants