fix(sync): stop re-opening a blocked issue while the PR state is unchanged - #211
Merged
alvarosanchez merged 3 commits intoSep 17, 2026
Merged
Conversation
…anged `shouldPreserveBlockedExternalPullRequestWait` preserves `blocked` for exactly two pull-request shapes: unfinished CI, and green CI waiting on maintainer approval. Neither covers `ciState === 'red'`, so an issue blocked on a red required check that nobody in the company can clear was pulled back into active work on every single sync pass. The contributor CLA gate is the case that bit us. The assignee cannot sign a CLA, so it blocks the issue; the next pass sees the red check and unblocks it; the assignee wakes, re-establishes that it is still blocked, and the pass after that does it again. On the cliponaut instance this wrote 3,233 `blocked -> in progress` transition comments across 40 issues, with the agents themselves recording "PR #N remains blocked only by the external CLA gate" and "GitHub Sync moved DEV-N back to N, but the linked PR ..." 119 times. An upstream baseline failure or an unreleased dependency produce the same loop. The existing effective-state fingerprint cannot suppress it, because the issue status genuinely changes each round: sync sets `in_progress`, the agent sets `blocked`, so the next pass compares against a different state and mutates again. `blocked` is a deliberate decision by an agent or a human, and GitHub Sync owns *changes* in GitHub, not steady state. So once sync has moved an issue out of `blocked`, it may not do so again until the externally observable state of the linked pull requests actually changes. That state — repository, number, head SHA, CI state, mergeability, merge state status, review decision, unresolved review threads — is hashed and persisted on the existing per-issue registries at the moment sync leaves `blocked`, and the hash is written at the mutation point because the registry writes further down are conditional. Anything sync can see changes the hash and normal routing resumes on the next pass. An issue with no linked pull request has no external state and is never held back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
The new logic clears unblockedExternalStateHash whenever nextStatus === 'blocked' (even when already blocked), which can erase the recorded hash without any external PR change and allow the re-opening loop to recur.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR addresses a sync-loop where GitHub Sync repeatedly transitions Paperclip issues from blocked back to active work when a linked PR remains externally blocked (e.g., permanently red required checks like a CLA gate), causing excessive status-transition comments and noisy wakeups.
Changes:
- Introduces an “external PR state hash” and a guard (
shouldPreserveDeliberateBlockedWait) so once sync unblocks an issue, it won’t do so again until the observable PR state changes. - Persists the last “unblocked” hash on the per-issue registries (
ImportedIssueRecord/RemoteActionRecord) at the mutation point to avoid losing it through conditional registry writes. - Adds targeted tests and updates SPEC/README to document the new
blockedpreservation rule.
File summaries
| File | Description |
|---|---|
| src/worker.ts | Adds external PR state hashing + deliberate-block preservation guard and persists the hash on relevant registries. |
| tests/plugin.spec.ts | Adds regression coverage for the “don’t re-open blocked on unchanged PR state” behavior and hash field coverage/order-independence. |
| SPEC.md | Documents the new invariant for not repeatedly moving issues out of blocked without external PR changes. |
| README.md | Explains the user-facing behavior and rationale for preventing repeated re-openings from blocked. |
Review details
Suppressed comments (1)
src/worker.ts:16097
- Same as the issue-linked path:
remoteAction.unblockedExternalStateHashis cleared whenevernextStatus === 'blocked', even when the issue is alreadyblockedand no fresh block occurred. This can drop the recorded external-state hash without any observable PR change and allow sync to re-open the issue again on an unchanged PR state. Clear the hash only when transitioning intoblockedfrom a non-blockedstatus.
} else if (paperclipIssue.status === 'blocked' && nextStatus !== 'blocked') {
remoteAction.unblockedExternalStateHash = currentExternalStateHash;
} else if (nextStatus === 'blocked') {
delete remoteAction.unblockedExternalStateHash;
}
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
`unlinkPaperclipIssueFromGitHub` tombstones the link record with `status: 'unlinked'` and drops the import-registry entry, which is how an operator says "this Paperclip issue is no longer the one for this GitHub issue". Nothing ever read that status back. `listImportedPaperclipIssuesForMapping` — the registry-repair path that answers "is this GitHub issue already imported?" — returned the tombstone like any other link, so the next sync re-adopted the very issue that had just been detached, rewrote the registry entry, and never imported a fresh one. Unlinking therefore did not survive a single sync pass. Honour the tombstone through a named `isLiveGitHubIssueLinkRecord` predicate. The second recovery path in the same function is already safe: unlinking clears the issue's GitHub origin and strips the link metadata from its description. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot caught that the hash was cleared whenever `nextStatus === 'blocked'`, including when the issue was already blocked and some other rule was simply preserving that status. That erased the recorded state with no external change, so the next pass could re-open on the same PR state and the loop returned. The three cases are now a named `resolveUnblockedExternalStateHash`, shared by both call sites instead of being written out twice: leaving `blocked` records the state, entering `blocked` from elsewhere resets the budget, and staying `blocked` keeps what was recorded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alvarosanchez
deleted the
fix/stop-reopening-blocked-issues-on-unchanged-pr-state
branch
September 17, 2026 17:01
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The loop
shouldPreserveBlockedExternalPullRequestWaitpreservesblockedfor exactly two pull-request shapes — unfinished CI, and green CI waiting on maintainer approval. Neither coversciState === 'red'.So an issue blocked on a red required check that nobody in the company can clear gets pulled back into active work on every sync pass:
blocked;in_progressand wakes the assignee;On the cliponaut instance this produced 3,233
blocked -> in progresstransition comments across 40 issues. The agents left the evidence themselves — "PR #N remains blocked only by the external CLA gate" ×71, and "GitHub Sync moved DEV-N back to N, but the linked PR …" ×119. One issue alone (DEV-83) carried 335 machine comments, cycling roughly every 17 minutes.An upstream baseline failure or an unreleased dependency behave identically; the CLA is just the common case here.
The existing effective-state fingerprint cannot suppress this, because the issue status genuinely changes each round — sync writes
in_progress, the agent writesblocked— so every pass compares against a different state and mutates again.The fix
blockedis a deliberate decision by an agent or a human, and GitHub Sync owns changes in GitHub, not steady state.Once sync has moved an issue out of
blocked, it may not do so again until the externally observable state of the linked pull requests actually changes.buildExternalPullRequestStateHashhashes repository, number, head SHA, CI state, mergeability, merge state status, review decision and the unresolved-review-thread flag (order-independent);shouldPreserveDeliberateBlockedWaitholdsblockedwhile the current hash equals the recorded one.The hash is persisted on the registries that already exist per issue —
ImportedIssueRecordfor the issue-linked path,RemoteActionRecordfor the direct-PR path — and written at the mutation point, because the registry writes further down both paths are conditional and would otherwise drop it. The direct-PR path re-resolves its record after persisting, since that path re-reads the registry.Deliberately narrow:
blocked; it can never force one into it;Tests
Two new cases in
tests/plugin.spec.ts, built on the exact PR shape that caused the loop (PR #1281, red CI,mergeStateStatus: blocked,review_required):blocked, and an issue with no PR is never held;npm test363/363 green,npm run typecheckclean.🤖 Generated with Claude Code