Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/duplicate-fix-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
---

ci: fail a PR at open time when an earlier open PR already declares a fix for
the same issue (#4588)

Release-nothing: adds `.github/workflows/duplicate-fix-guard.yml` and updates
agent process docs (AGENTS.md, CLAUDE.md, pm-dispatch claim template) — no
package code.

GitHub lets any number of open PRs declare `Fixes #N` for the same issue.
On 2026-08-02, #4555 and #4559 both declared `Fixes #4551` and both were
implemented in full — 834 duplicate lines through the whole gate suite — with
the duplication machine-detectable from the second PR's open (03:08) yet
unnoticed by any human until 08:52. The shared GitHub identity made the
issue's assignee useless as a warning: "assigned to os-zhuang" reads the same
whether the claimant is you or another session.

Three changes, one per hole:

- **Duplicate Fix Guard workflow**: on PR opened/edited/reopened/synchronize,
parse same-repo closing keywords and fail the PR if an EARLIER open PR
(lower number) declares the same issue, naming it. First come, first
served — matching the pm-dispatch "first claim comment wins" convention.
The check is body-driven and re-runs on `edited`, so a red PR goes green
the moment the conflict is resolved either way.
- **Claim comments must carry a session ID** (pm-dispatch template, AGENTS.md,
CLAUDE.md): under a shared identity, the comment's session line is the only
thing that makes "is this claim mine?" answerable.
- **Branch naming `claude/issue-<n>-<slug>`** (AGENTS.md): puts the issue
number where `git ls-remote | grep issue-<n>` can find it; the workflow
warns (never fails) when a fix PR's branch names no declared issue.
12 changes: 9 additions & 3 deletions .claude/skills/pm-dispatch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,14 +189,20 @@ execute atomically, in order:
1. **Assign** to yourself (`@me`) and add `pm:dispatched`. Skip — and drop
from the batch — any issue that acquired an assignee since step 1.
2. **Claim comment** (Chinese), fixed shape — the branch name is the key,
every later artifact (worktree, push, PR) hangs off it:
every later artifact (worktree, push, PR) hangs off it. The session ID is
NOT optional: under the shared identity it is the only line that lets a
later reader — including your own future self after a context reset —
answer "is this claim mine?". A claim without it caused the #4555/#4559
duplicate (#4588): the second session saw its own shared name as assignee
and could not tell the claim was someone else's.
> 认领:PM 循环第 N 轮
> 会话:`session_<id>`
> 分支:`claude/issue-<n>-<slug>`
> Worktree:`<repo>-issue-<n>`
3. **Race check**: assignment is idempotent, so two agents can both
"succeed". Re-read the comments; if an earlier claim comment with a
*different* branch name exists, you lost — touch nothing of theirs,
reply 「已有认领,让行」, and pick another issue. First comment wins.
*different* session ID or branch name exists, you lost — touch nothing of
theirs, reply 「已有认领,让行」, and pick another issue. First comment wins.

Dev agents push their branch early — a remote branch is the hardest evidence
of work in flight, closing the gap between "claimed" and "PR exists".
Expand Down
140 changes: 140 additions & 0 deletions .github/workflows/duplicate-fix-guard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# GitHub happily lets any number of open PRs declare `Fixes #N` for the same
# issue. On 2026-08-02 that cost a full duplicate implementation: #4555 and
# #4559 both declared `Fixes #4551`, both ran the whole gate suite green, and
# the duplication sat machine-detectable from the moment the second PR opened
# (03:08) until a human noticed it (08:52). All agents here share one GitHub
# identity, so the issue's assignee could not warn the second author either —
# "assigned to os-zhuang" reads the same whether it is you or another session.
# Full post-mortem: #4588.
#
# This gate makes the second PR red at open time. First come, first served —
# the EARLIER open PR (lower number) keeps its claim and stays green; the
# later one fails with a pointer to it. That matches the pm-dispatch claim
# convention ("first claim comment wins") already in .claude/skills/.
#
# Scope: same-repo references only (bare `#N` and qualified `<this repo>#N`).
# Cross-repo references are the cross-repo-issue-closer's territory, and a
# duplicate across repos cannot be resolved by failing one side's CI anyway.
name: Duplicate Fix Guard

# `edited` matters as much as `opened`: a PR that adds `Fixes #N` to its body
# after the fact must re-run this check, and one that drops the line must be
# able to go green again.
on:
pull_request:
types: [opened, edited, reopened, synchronize]

permissions:
pull-requests: read

jobs:
duplicate-fix-guard:
name: No other open PR may claim the same issue
runs-on: ubuntu-latest
steps:
- name: Check declared issues against other open PRs
uses: actions/github-script@v9
with:
script: |
const pr = context.payload.pull_request;
const thisRepo = `${context.repo.owner}/${context.repo.repo}`;

// GitHub's own closing-keyword set. The optional colon is part of
// GitHub's accepted syntax (`Fixes: #123`). Two same-repo forms:
// bare `#N` and qualified `owner/repo#N` naming THIS repo.
const KEYWORDS = 'close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved';
const pattern = new RegExp(
`\\b(?:${KEYWORDS}):?\\s+(?:([\\w.-]+)\\/([\\w.-]+))?#(\\d+)\\b`,
'gi',
);

// Strip fenced blocks and inline code spans before matching.
// GitHub's own closing-keyword parser ignores code formatting, and
// this guard must not be stricter than the linker it protects:
// this very PR's first run counted its own DISCUSSION of
// `Fixes #4551` (in backticks, describing the incident) as a
// declaration. Benign there — #4551 is closed — but a prose
// mention of an issue some other open PR really fixes would have
// been a spurious red.
const declaredIssues = (body) => {
const prose = (body || '')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`[^`\n]*`/g, ' ');
const found = new Set();
for (const [, owner, repo, number] of prose.matchAll(pattern)) {
// A qualified reference to ANOTHER repo is not ours to judge.
if (owner && `${owner}/${repo}`.toLowerCase() !== thisRepo.toLowerCase()) continue;
found.add(Number(number));
}
return found;
};

const mine = declaredIssues(pr.body);
if (mine.size === 0) {
core.info('This PR declares no same-repo closing keywords — nothing to guard.');
return;
}
core.info(`This PR declares: ${[...mine].map((n) => `#${n}`).join(', ')}`);

// Branch-name convention (advisory, never red): a fix branch named
// `claude/issue-<n>-<slug>` is discoverable by the next session
// with one `git ls-remote | grep issue-<n>`. #4555 vs #4559
// happened partly because the branches shared no token to grep.
// Warning only — existing branches must not go red retroactively.
const branch = pr.head.ref;
if (![...mine].some((n) => branch.includes(`issue-${n}`))) {
core.warning(
`Branch \`${branch}\` does not name any declared issue. ` +
`Convention: claude/issue-<n>-<slug> (e.g. claude/issue-${[...mine][0]}-short-slug) ` +
`so parallel sessions can discover in-flight work with git ls-remote.`,
);
}

// Drafts count: a draft PR is work in flight, which is exactly
// what the second session needs to see.
const openPrs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
});

const conflicts = [];
for (const other of openPrs) {
if (other.number === pr.number) continue;
const theirs = declaredIssues(other.body);
const shared = [...mine].filter((n) => theirs.has(n));
if (shared.length > 0) conflicts.push({ other, shared });
}

if (conflicts.length === 0) {
core.info('No other open PR declares these issues.');
return;
}

// First come, first served: only the LATER PR goes red. Failing
// both would leave the original author red through no action of
// their own; failing the earlier one would reward racing.
const older = conflicts.filter((c) => c.other.number < pr.number);
const newer = conflicts.filter((c) => c.other.number > pr.number);

for (const { other, shared } of newer) {
core.info(
`#${other.number} (newer) also declares ${shared.map((n) => `#${n}`).join(', ')} — ` +
`it will fail its own run of this guard; this PR keeps its claim.`,
);
}

if (older.length > 0) {
const lines = older.map(({ other, shared }) =>
` - ${shared.map((n) => `#${n}`).join(', ')} is already claimed by #${other.number} ` +
`(${other.html_url}, branch \`${other.head.ref}\`${other.draft ? ', draft' : ''})`,
);
core.setFailed(
`Another open PR already declares a fix for the same issue(s):\n${lines.join('\n')}\n` +
`If this PR is the duplicate, close it and add anything it uniquely covers to the ` +
`earlier PR or a follow-up issue (that is what saved #4560 when #4559 was closed). ` +
`If the EARLIER one is abandoned, close it first — this check re-runs on 'edited' ` +
`and 'synchronize', and goes green once the conflict is gone.`,
);
}
20 changes: 19 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,18 @@ then race to land conflicting shapes for the same problem, which is worse than
either one alone. If it is already assigned to someone else it is taken — pick
another, or say so and ask; never reassign it to yourself.

Because every agent here shares one GitHub identity, the assignee field alone
cannot answer "is this claim *mine*?" — seeing your own shared name on an issue
is exactly what another session's claim looks like. So a claim is two acts, not
one: assign, **and leave a claim comment carrying your session ID and branch
name** (`claude/issue-<n>-<slug>`). Before writing code, re-read the issue's
comments; an earlier claim comment with a different session ID or branch means
the issue is taken no matter what the assignee field seems to say. Skipping
this read is how #4551 got implemented twice in one morning (#4555 and #4559 —
post-mortem in #4588), and misreading shared-identity state is also how a
maintainer's manual ready-flip got reverted by an agent that assumed its own
write had failed.

The claim is also what makes the *finding* rule (Prime Directive #10) safe to
follow. Once out-of-scope discoveries become issues, the issue list is a real
queue other agents read, and a claim is the only thing separating "someone is on
Expand All @@ -129,7 +141,13 @@ Even inside your own worktree, operate defensively:
reverts, or other agents' in-flight edits, and don't try to manage the whole
working tree. If a file you didn't change shows as modified, leave it.
2. **One feature branch + one PR per task.** Branch off `main`. **Never commit
task work straight to `main`.**
task work straight to `main`.** Name the branch after the issue it fixes:
`claude/issue-<n>-<slug>`. The issue number in the name is what makes
in-flight work *discoverable* — `git ls-remote --heads origin | grep
issue-<n>` is a one-command pre-check, and the Duplicate Fix Guard workflow
warns on fix PRs whose branch names no declared issue. The #4555/#4559
duplicate (#4588) stayed invisible partly because one branch carried the
issue number and the other didn't.
3. **Never `git push --force` / `--force-with-lease`, and never push `main`.** A
force-push can clobber a parallel agent's work; `main` is shared — land
everything via PR.
Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ conflicting shapes for one problem. Already assigned to someone else? It is take
another or ask; never reassign it to yourself. File findings unassigned when you are only
recording them; assign at the moment you start.

All agents share one GitHub identity, so the assignee field can't tell you whether a claim
is **yours** — a claim is assign **plus a claim comment with your session ID and branch**
(`claude/issue-<n>-<slug>`), and before writing code you must re-read the comments: an
earlier claim with a different session ID means it's taken, whatever the assignee says.
(#4551 was implemented twice in one morning because this read was skipped — see #4588.)

## ⛔ Worktree-first — before your FIRST file edit (AGENTS.md Prime Directive #11)

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