Skip to content

Commit b05b537

Browse files
committed
ci: fail a PR at open time when an earlier open PR already fixes the same issue (#4588)
GitHub lets any number of open PRs declare Fixes #N for the same issue; #4555 and #4559 proved the cost this morning. Three changes, one per hole: - duplicate-fix-guard.yml: same-repo closing keywords are checked against every other open PR; the LATER PR goes red with a pointer to the earlier one (first come, first served, matching the pm-dispatch claim convention). Re-runs on 'edited'/'synchronize' so resolving the conflict either way clears the red. Branch names that carry no declared issue number get a warning, never a failure. - Claim comments now require a session ID (pm-dispatch template, AGENTS.md, CLAUDE.md): under the shared GitHub identity the assignee field cannot answer "is this claim mine?" — the comment's session line is what can. - Branch naming claude/issue-<n>-<slug> documented in AGENTS.md so git ls-remote | grep issue-<n> is a one-command pre-check. Script validated with node --check under the full injected-identifier wrapper; keyword regex exercised against 10 samples including the prefixes-#N false-positive and cross-repo exclusions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD
1 parent 355e951 commit b05b537

5 files changed

Lines changed: 195 additions & 4 deletions

File tree

.changeset/duplicate-fix-guard.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
---
3+
4+
ci: fail a PR at open time when an earlier open PR already declares a fix for
5+
the same issue (#4588)
6+
7+
Release-nothing: adds `.github/workflows/duplicate-fix-guard.yml` and updates
8+
agent process docs (AGENTS.md, CLAUDE.md, pm-dispatch claim template) — no
9+
package code.
10+
11+
GitHub lets any number of open PRs declare `Fixes #N` for the same issue.
12+
On 2026-08-02, #4555 and #4559 both declared `Fixes #4551` and both were
13+
implemented in full — 834 duplicate lines through the whole gate suite — with
14+
the duplication machine-detectable from the second PR's open (03:08) yet
15+
unnoticed by any human until 08:52. The shared GitHub identity made the
16+
issue's assignee useless as a warning: "assigned to os-zhuang" reads the same
17+
whether the claimant is you or another session.
18+
19+
Three changes, one per hole:
20+
21+
- **Duplicate Fix Guard workflow**: on PR opened/edited/reopened/synchronize,
22+
parse same-repo closing keywords and fail the PR if an EARLIER open PR
23+
(lower number) declares the same issue, naming it. First come, first
24+
served — matching the pm-dispatch "first claim comment wins" convention.
25+
The check is body-driven and re-runs on `edited`, so a red PR goes green
26+
the moment the conflict is resolved either way.
27+
- **Claim comments must carry a session ID** (pm-dispatch template, AGENTS.md,
28+
CLAUDE.md): under a shared identity, the comment's session line is the only
29+
thing that makes "is this claim mine?" answerable.
30+
- **Branch naming `claude/issue-<n>-<slug>`** (AGENTS.md): puts the issue
31+
number where `git ls-remote | grep issue-<n>` can find it; the workflow
32+
warns (never fails) when a fix PR's branch names no declared issue.

.claude/skills/pm-dispatch/SKILL.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,14 +189,20 @@ execute atomically, in order:
189189
1. **Assign** to yourself (`@me`) and add `pm:dispatched`. Skip — and drop
190190
from the batch — any issue that acquired an assignee since step 1.
191191
2. **Claim comment** (Chinese), fixed shape — the branch name is the key,
192-
every later artifact (worktree, push, PR) hangs off it:
192+
every later artifact (worktree, push, PR) hangs off it. The session ID is
193+
NOT optional: under the shared identity it is the only line that lets a
194+
later reader — including your own future self after a context reset —
195+
answer "is this claim mine?". A claim without it caused the #4555/#4559
196+
duplicate (#4588): the second session saw its own shared name as assignee
197+
and could not tell the claim was someone else's.
193198
> 认领:PM 循环第 N 轮
199+
> 会话:`session_<id>`
194200
> 分支:`claude/issue-<n>-<slug>`
195201
> Worktree:`<repo>-issue-<n>`
196202
3. **Race check**: assignment is idempotent, so two agents can both
197203
"succeed". Re-read the comments; if an earlier claim comment with a
198-
*different* branch name exists, you lost — touch nothing of theirs,
199-
reply 「已有认领,让行」, and pick another issue. First comment wins.
204+
*different* session ID or branch name exists, you lost — touch nothing of
205+
theirs, reply 「已有认领,让行」, and pick another issue. First comment wins.
200206

201207
Dev agents push their branch early — a remote branch is the hardest evidence
202208
of work in flight, closing the gap between "claimed" and "PR exists".
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# GitHub happily lets any number of open PRs declare `Fixes #N` for the same
2+
# issue. On 2026-08-02 that cost a full duplicate implementation: #4555 and
3+
# #4559 both declared `Fixes #4551`, both ran the whole gate suite green, and
4+
# the duplication sat machine-detectable from the moment the second PR opened
5+
# (03:08) until a human noticed it (08:52). All agents here share one GitHub
6+
# identity, so the issue's assignee could not warn the second author either —
7+
# "assigned to os-zhuang" reads the same whether it is you or another session.
8+
# Full post-mortem: #4588.
9+
#
10+
# This gate makes the second PR red at open time. First come, first served —
11+
# the EARLIER open PR (lower number) keeps its claim and stays green; the
12+
# later one fails with a pointer to it. That matches the pm-dispatch claim
13+
# convention ("first claim comment wins") already in .claude/skills/.
14+
#
15+
# Scope: same-repo references only (bare `#N` and qualified `<this repo>#N`).
16+
# Cross-repo references are the cross-repo-issue-closer's territory, and a
17+
# duplicate across repos cannot be resolved by failing one side's CI anyway.
18+
name: Duplicate Fix Guard
19+
20+
# `edited` matters as much as `opened`: a PR that adds `Fixes #N` to its body
21+
# after the fact must re-run this check, and one that drops the line must be
22+
# able to go green again.
23+
on:
24+
pull_request:
25+
types: [opened, edited, reopened, synchronize]
26+
27+
permissions:
28+
pull-requests: read
29+
30+
jobs:
31+
duplicate-fix-guard:
32+
name: No other open PR may claim the same issue
33+
runs-on: ubuntu-latest
34+
steps:
35+
- name: Check declared issues against other open PRs
36+
uses: actions/github-script@v9
37+
with:
38+
script: |
39+
const pr = context.payload.pull_request;
40+
const thisRepo = `${context.repo.owner}/${context.repo.repo}`;
41+
42+
// GitHub's own closing-keyword set. The optional colon is part of
43+
// GitHub's accepted syntax (`Fixes: #123`). Two same-repo forms:
44+
// bare `#N` and qualified `owner/repo#N` naming THIS repo.
45+
const KEYWORDS = 'close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved';
46+
const pattern = new RegExp(
47+
`\\b(?:${KEYWORDS}):?\\s+(?:([\\w.-]+)\\/([\\w.-]+))?#(\\d+)\\b`,
48+
'gi',
49+
);
50+
51+
const declaredIssues = (body) => {
52+
const found = new Set();
53+
for (const [, owner, repo, number] of (body || '').matchAll(pattern)) {
54+
// A qualified reference to ANOTHER repo is not ours to judge.
55+
if (owner && `${owner}/${repo}`.toLowerCase() !== thisRepo.toLowerCase()) continue;
56+
found.add(Number(number));
57+
}
58+
return found;
59+
};
60+
61+
const mine = declaredIssues(pr.body);
62+
if (mine.size === 0) {
63+
core.info('This PR declares no same-repo closing keywords — nothing to guard.');
64+
return;
65+
}
66+
core.info(`This PR declares: ${[...mine].map((n) => `#${n}`).join(', ')}`);
67+
68+
// Branch-name convention (advisory, never red): a fix branch named
69+
// `claude/issue-<n>-<slug>` is discoverable by the next session
70+
// with one `git ls-remote | grep issue-<n>`. #4555 vs #4559
71+
// happened partly because the branches shared no token to grep.
72+
// Warning only — existing branches must not go red retroactively.
73+
const branch = pr.head.ref;
74+
if (![...mine].some((n) => branch.includes(`issue-${n}`))) {
75+
core.warning(
76+
`Branch \`${branch}\` does not name any declared issue. ` +
77+
`Convention: claude/issue-<n>-<slug> (e.g. claude/issue-${[...mine][0]}-short-slug) ` +
78+
`so parallel sessions can discover in-flight work with git ls-remote.`,
79+
);
80+
}
81+
82+
// Drafts count: a draft PR is work in flight, which is exactly
83+
// what the second session needs to see.
84+
const openPrs = await github.paginate(github.rest.pulls.list, {
85+
owner: context.repo.owner,
86+
repo: context.repo.repo,
87+
state: 'open',
88+
per_page: 100,
89+
});
90+
91+
const conflicts = [];
92+
for (const other of openPrs) {
93+
if (other.number === pr.number) continue;
94+
const theirs = declaredIssues(other.body);
95+
const shared = [...mine].filter((n) => theirs.has(n));
96+
if (shared.length > 0) conflicts.push({ other, shared });
97+
}
98+
99+
if (conflicts.length === 0) {
100+
core.info('No other open PR declares these issues.');
101+
return;
102+
}
103+
104+
// First come, first served: only the LATER PR goes red. Failing
105+
// both would leave the original author red through no action of
106+
// their own; failing the earlier one would reward racing.
107+
const older = conflicts.filter((c) => c.other.number < pr.number);
108+
const newer = conflicts.filter((c) => c.other.number > pr.number);
109+
110+
for (const { other, shared } of newer) {
111+
core.info(
112+
`#${other.number} (newer) also declares ${shared.map((n) => `#${n}`).join(', ')} — ` +
113+
`it will fail its own run of this guard; this PR keeps its claim.`,
114+
);
115+
}
116+
117+
if (older.length > 0) {
118+
const lines = older.map(({ other, shared }) =>
119+
` - ${shared.map((n) => `#${n}`).join(', ')} is already claimed by #${other.number} ` +
120+
`(${other.html_url}, branch \`${other.head.ref}\`${other.draft ? ', draft' : ''})`,
121+
);
122+
core.setFailed(
123+
`Another open PR already declares a fix for the same issue(s):\n${lines.join('\n')}\n` +
124+
`If this PR is the duplicate, close it and add anything it uniquely covers to the ` +
125+
`earlier PR or a follow-up issue (that is what saved #4560 when #4559 was closed). ` +
126+
`If the EARLIER one is abandoned, close it first — this check re-runs on 'edited' ` +
127+
`and 'synchronize', and goes green once the conflict is gone.`,
128+
);
129+
}

AGENTS.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,18 @@ then race to land conflicting shapes for the same problem, which is worse than
117117
either one alone. If it is already assigned to someone else it is taken — pick
118118
another, or say so and ask; never reassign it to yourself.
119119

120+
Because every agent here shares one GitHub identity, the assignee field alone
121+
cannot answer "is this claim *mine*?" — seeing your own shared name on an issue
122+
is exactly what another session's claim looks like. So a claim is two acts, not
123+
one: assign, **and leave a claim comment carrying your session ID and branch
124+
name** (`claude/issue-<n>-<slug>`). Before writing code, re-read the issue's
125+
comments; an earlier claim comment with a different session ID or branch means
126+
the issue is taken no matter what the assignee field seems to say. Skipping
127+
this read is how #4551 got implemented twice in one morning (#4555 and #4559
128+
post-mortem in #4588), and misreading shared-identity state is also how a
129+
maintainer's manual ready-flip got reverted by an agent that assumed its own
130+
write had failed.
131+
120132
The claim is also what makes the *finding* rule (Prime Directive #10) safe to
121133
follow. Once out-of-scope discoveries become issues, the issue list is a real
122134
queue other agents read, and a claim is the only thing separating "someone is on
@@ -129,7 +141,13 @@ Even inside your own worktree, operate defensively:
129141
reverts, or other agents' in-flight edits, and don't try to manage the whole
130142
working tree. If a file you didn't change shows as modified, leave it.
131143
2. **One feature branch + one PR per task.** Branch off `main`. **Never commit
132-
task work straight to `main`.**
144+
task work straight to `main`.** Name the branch after the issue it fixes:
145+
`claude/issue-<n>-<slug>`. The issue number in the name is what makes
146+
in-flight work *discoverable* — `git ls-remote --heads origin | grep
147+
issue-<n>` is a one-command pre-check, and the Duplicate Fix Guard workflow
148+
warns on fix PRs whose branch names no declared issue. The #4555/#4559
149+
duplicate (#4588) stayed invisible partly because one branch carried the
150+
issue number and the other didn't.
133151
3. **Never `git push --force` / `--force-with-lease`, and never push `main`.** A
134152
force-push can clobber a parallel agent's work; `main` is shared — land
135153
everything via PR.

CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ conflicting shapes for one problem. Already assigned to someone else? It is take
1515
another or ask; never reassign it to yourself. File findings unassigned when you are only
1616
recording them; assign at the moment you start.
1717

18+
All agents share one GitHub identity, so the assignee field can't tell you whether a claim
19+
is **yours** — a claim is assign **plus a claim comment with your session ID and branch**
20+
(`claude/issue-<n>-<slug>`), and before writing code you must re-read the comments: an
21+
earlier claim with a different session ID means it's taken, whatever the assignee says.
22+
(#4551 was implemented twice in one morning because this read was skipped — see #4588.)
23+
1824
## ⛔ Worktree-first — before your FIRST file edit (AGENTS.md Prime Directive #11)
1925

2026
This repo — **and every sibling repo you touch (`objectui`, `cloud`)** — is edited by

0 commit comments

Comments
 (0)