fix(review): cap review loops (same-SHA + MAX_ROUNDS + diff-scoping) #118
No reviewers
Labels
No labels
area:agents
area:dashboard
area:database
area:design
area:design-review
area:flows
area:infra
area:meta
area:security
area:sessions
area:webhook
area:workdir
security
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/claude-hooks!118
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/review-loop-terminators"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Three terminators for the reviewer ↔ author cycle, motivated by PR #115 — which ran 5 review rounds in 90 minutes, including one duplicate review on the same SHA and one regression the author introduced while fixing the prior round. Each round was 5 min × 2 agents × 1M context. The PR eventually merged when I pushed the final fix by hand — exactly the outcome we want to avoid next time.
What lands
1. Same-SHA guard (
handleReviewRequested)decidePostCiActionalready had averdictAtHeadcheck for the CI-green path. The explicit-review-request path (operator re-request, bounce fromdecidePostCiAction,create_review_requests) went straight to dispatch. Now both paths check the same thing:2. Round cap (
handleChangesRequested) —MAX_ROUNDS = 3Counts
REQUEST_CHANGESreviews by the reviewer on this PR. At or above 3, the author-side dispatch is halted and a comment is posted tagging the operator with concrete unstick options:APPROVEDreview → merge dispatch fires)3 rounds is the default because R1 finds real bugs, R2 catches regressions from R1-fixes, R3 is the last-chance polish pass, and anything past R3 is usually nitpick spiral or reviewer non-determinism.
3. Diff-scoped reviewer skill
skills/review.mdadds a pre-checklist step: the reviewer counts its own prior reviews vialist_pull_reviews. Round 1 reviews everything; round 2+ only the delta since its last submitted review body. Don't re-raise fixed findings; don't hunt for new nits on unchanged code.Shape
New module
src/review-loop.ts:MAX_ROUNDS = 3constantcountChangeRequestRounds(reviews, login)— pure counterhasVerdictAtHead(reviews, login, sha)— pure predicate (mirrorsverdictAtHeadindecidePostCiAction, exported for reuse)guardReviewerDispatch(args)— fetch-once wrapper for the reviewer pathguardAuthorDispatch(args)— fetch-once wrapper for the author path; posts operator-handoff comment on cappostOperatorHandoff(args)— composer for the handoff commentPlumbed into
webhook-handlers.ts::handleReviewRequested(same-SHA guard) andwebhook-handlers.ts::handleChangesRequested(round cap). No state persisted — counters derive from Forgejo's submitted-review history.Adds
createIssueComment(repo, issueNumber, body, token)toforgejo-api.tsfor the handoff comment.Test plan
bunx tsc --noEmit— cleanbunx biome check src/— cleanbun test src/review-loop.test.ts— 17 new tests pass:MAX_ROUNDS, ignores APPROVED in count, ignores other-user reviews, posts handoff comment with expected bodymain-agents.test.ts(docker volume rmexpected to fail in test env but succeeds on hosts with a Docker daemon — unrelated to this PR; already failing onmain)What this does NOT do
match_labelsgives us the mechanism when we do.Self-application
This PR will itself be subject to the new cap once it lands. If the reviewer exceeds 3 rounds on this PR, that's a useful dogfood signal that the cap is too low. For now I expect 1-2 rounds max — changes are small and well-scoped.
🤖 Generated with Claude Code
Review: APPROVED ✅
CI is green (run #1680, 2m55s). All three terminators are implemented correctly and well-tested.
What lands
handleReviewRequestedMAX_ROUNDS = 3cap inhandleChangesRequestedskills/review.md)createIssueCommentinforgejo-api.tsOne doc bug to fix in a follow-up
src/review-loop.ts—postOperatorHandoffdocstring, opening sentence:This is factually incorrect — the implementation never calls
listIssueCommentsand has no guard against re-posting. The marker is embedded in the comment body but never checked. The correct explanation is already in the second paragraph of the same docstring:Fix: Remove the false "Idempotent via the marker string" sentence; keep the rest. Operationally this only matters if the operator manually pushes past the cap and the reviewer submits a 4th REQUEST_CHANGES, which would post a second handoff comment — low impact, fine to fix in a follow-up.
Minor notes (non-blocking)
getPullRequestreturningnullinhandleReviewRequested(guard skipped whenheadShais undefined) is correct — consistent withguardReviewerDispatch's own fail-open on 500.reviewerForAuthorpassed asreviewerLogin: string— TypeScript enforces the contract at compile time.MAX_ROUNDS + 2(not just boundary) is good defensive coverage.Merge when ready.