Skip to content

fix(sync): stop re-opening a blocked issue while the PR state is unchanged - #211

Merged
alvarosanchez merged 3 commits into
mainfrom
fix/stop-reopening-blocked-issues-on-unchanged-pr-state
Sep 17, 2026
Merged

alvarosanchez merged 3 commits into
mainfrom
fix/stop-reopening-blocked-issues-on-unchanged-pr-state

Conversation

@alvarosanchez

Copy link
Copy Markdown
Owner

The loop

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 gets pulled back into active work on every sync pass:

  1. the assignee cannot clear the check (a contributor CLA gate needs a human signature), so it sets the issue blocked;
  2. the next sync pass sees red CI, moves it to in_progress and wakes the assignee;
  3. the assignee re-establishes that it is still externally blocked and blocks it again;
  4. go to 2.

On the cliponaut instance this produced 3,233 blocked -> in progress transition 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 writes blocked — so every pass compares against a different state and mutates again.

The fix

blocked is 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. buildExternalPullRequestStateHash hashes repository, number, head SHA, CI state, mergeability, merge state status, review decision and the unresolved-review-thread flag (order-independent); shouldPreserveDeliberateBlockedWait holds blocked while the current hash equals the recorded one.

The hash is persisted on the registries that already exist per issue — ImportedIssueRecord for the issue-linked path, RemoteActionRecord for 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:

  • it only ever keeps an issue in blocked; it can never force one into it;
  • an issue with no linked PR has no external state and is never held back;
  • a fresh block clears the recorded hash, so the next external change can unblock normally;
  • any change sync can see — a push, a CI result, a review, a resolved conflict — resumes routing on the very next pass.

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):

  • the first pass unblocks, the second pass on identical state does not, and a new head SHA or the CLA going green unblocks again; the guard never forces blocked, and an issue with no PR is never held;
  • the hash is order-independent and every field sync reacts to is covered.

npm test 363/363 green, npm run typecheck clean.

🤖 Generated with Claude Code

…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>
Copilot AI lite review requested due to automatic review settings September 17, 2026 16:53

Copilot AI 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.

🟡 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 blocked preservation 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.unblockedExternalStateHash is cleared whenever nextStatus === 'blocked', even when the issue is already blocked and 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 into blocked from a non-blocked status.
      } 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.

Comment thread src/worker.ts Outdated
alvarosanchez and others added 2 commits September 17, 2026 16:58
`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
alvarosanchez merged commit d06bf86 into main Sep 17, 2026
1 check passed
@alvarosanchez
alvarosanchez deleted the fix/stop-reopening-blocked-issues-on-unchanged-pr-state branch September 17, 2026 17:01
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