Skip to content

Commit bb170b3

Browse files
mmckyclaude
andauthored
fix(review): keep exactly one review comment under concurrent runs (#98)
* fix(review): keep exactly one review comment under concurrent runs postReviewComment listed comments, looked for its own, then created one if absent — a check-then-act with nothing making it atomic. Concurrent runs all observed "no comment yet" and each created one. Concurrency here is routine, not exceptional: one sync fires `opened` plus a `labeled` event per label, so lecture-python-programming.fr#6 got five runs in four seconds, two surviving comments, and scores that matched no single review (each had been overwritten by a different run than the one that created it). Issue comments have no conditional-write primitive, so the fix reconciles instead of locking. Every comment carries a hidden marker (`<!-- action-translation-review -->`); after writing, a run deletes every marked comment with a lower id. Ids increase with creation time and each run lists after it writes, so the run holding the highest id always sees and removes the rest — one comment survives any interleaving. Note this is the mirror of the rule sketched in #96: deleting *newer* ids leaves duplicates whenever the winner lists before a slower run creates. Matching is anchored at the start of the body. The old prose predicate matched "Translation Quality Review" and "action-translation" anywhere in a comment, so it could adopt — and now would delete — a human comment quoting a review. Pre-marker comments still match by their generated heading, so the duplicates already on .fr#6 self-heal on its next review. Converging the comment does not converge the spend: each racing run still pays for a full review. That is workflow-side, and the trigger list #96 blames on the target repo is shipped by our own docs — connect-existing.md, which .fa also follows. All four review templates gain a per-PR `concurrency` group; the one triggering on `labeled` now ignores labels other than `action-translation` (`labeled` itself is load-bearing — labels land after the PR opens); and all declare `permissions` explicitly, since removing a duplicate needs `pull-requests: write`. Tests drive concurrent reviewers against a shared fake comments API; the natural await interleaving reproduces the race. Verified they fail against the pre-fix implementation (5 comments for 5 runs). Refs #96 Workflow-side fix in QuantEcon/lecture-python-programming.fr#7 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(dev): track PR #98 in STATE; note the #96 sighting against #92 Leaves `verified:` at 2026-07-15 and the Health line describing main as-is — this only records the in-flight PR and the #92-family lead #96 turned up, it is not a full re-verification of the document. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ed47e9c commit bb170b3

12 files changed

Lines changed: 651 additions & 57 deletions

File tree

.dev/STATE.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ Roadmap detail lives in [PLAN.md](PLAN.md), not here.
77

88
## In flight
99

10+
- **PR #98**#96: duplicate review comments under concurrent runs (unsynchronised upsert).
11+
Marker + delete-older-ids reconciliation; also adds the `concurrency` group missing from
12+
all four review workflow templates, which is where `.fr`/`.fa` inherited the 5x review
13+
spend. Decision: `decisions/D-2026-07-16-single-review-comment.md`.
1014
- **PR #78** — fr programming-domain glossary terms; awaiting native review.
1115
- **PR #71** — Malayalam (`ml`) draft; awaiting native-reviewer calibration batch.
1216
Glossary PR **#69** (ja) open, awaiting native review + a `LANGUAGE_CONFIGS` entry.
@@ -53,7 +57,9 @@ Roadmap detail lives in [PLAN.md](PLAN.md), not here.
5357
Phase 2 round-trip test would catch three of them as a class, so Phase 2 first.
5458
- Smaller review-round follow-ups: **#91** (heading-maps.md documents a key format the action
5559
has never written — nearly caused a bad rubric fix), **#92** (PR creation reports failure
56-
when the API times out *after* succeeding; naive retry would duplicate).
60+
when the API times out *after* succeeding; naive retry would duplicate — #96 flags a
61+
possible second sighting: four `labeled` events recorded for two `addLabels` calls,
62+
unreproduced, mechanism unknown).
5763
- Earlier review-round issues: **#81** (typography on sync path), **#82** (model-swap
5864
eval — see REVIEW §7.4 for a concrete deterministic design).
5965

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# One review comment per PR: hidden marker + delete-older reconciliation
2+
3+
**Context**: `postReviewComment` was a check-then-act (list → find → update-or-create) with no
4+
lock, so concurrent review runs each observed "no comment yet" and each created a comment
5+
(#96). Concurrency is routine, not exceptional: one sync fires `opened` plus a `labeled` event
6+
per label, which is five qualifying events in four seconds on `lecture-python-programming.fr#6`.
7+
Issue comments have no conditional-write primitive (no ETag/If-Match, no unique key), so the
8+
race cannot be closed by retrying — both creates genuinely succeed.
9+
10+
**Decision**: Every review comment carries a hidden `<!-- action-translation-review -->` marker
11+
on its first line. After writing (create *or* update), a run lists again and deletes every
12+
marked comment with an id **lower** than the one it wrote — newest wins.
13+
14+
Delete-older converges; delete-newer does not. Ids increase with creation time, and each run
15+
lists strictly after it writes. So for any two of our comments `ci < cj`, the run owning `cj`
16+
listed after `cj` was created, hence after `ci` was created, hence it sees and deletes `ci`.
17+
Only the highest id survives, whatever the interleaving. The mirror rule #96 suggested
18+
("delete ids greater than yours", keeping the oldest) leaves duplicates whenever the winner
19+
lists before a slower run creates — the exact interleaving that produced the bug.
20+
21+
Matching is anchored at the *start* of the body (marker, or the generated `## … Translation
22+
Quality Review` heading for pre-marker comments). These comments get deleted, so a loose
23+
predicate destroys data: the old prose match — "Translation Quality Review" and
24+
"action-translation" appearing anywhere — would match a human comment quoting a review.
25+
26+
**Consequences**:
27+
28+
- Exactly one review comment per PR under any concurrency. The common path (re-review after a
29+
push) still updates in place, so the comment keeps its position in the thread.
30+
- Duplicates from v0.16.1 and earlier are marker-less; the legacy heading match adopts the
31+
newest as ours and deletes the rest, so affected PRs self-heal on their next review.
32+
- Costs one extra `listComments` per review, and needs `pull-requests: write` to delete.
33+
Deletes are best-effort — a failed delete leaves a duplicate, which is not worth failing a
34+
review that posted successfully.
35+
- Converging the comment does **not** converge the spend: every racing run still pays for a
36+
full review. That is a workflow-side problem, fixed by the `concurrency` group and the
37+
`labeled` guard now in the review templates.
38+
39+
**Refs**: QuantEcon/action-translation#96 · workflow-side fix
40+
QuantEcon/lecture-python-programming.fr#7 · tests in `src/__tests__/reviewer-comment.test.ts`
41+
drive concurrent reviewers against a shared fake comments API.

.dev/log/2026-07-16-dupcomment.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# 2026-07-16 — #96: duplicate review comments under concurrent runs
2+
3+
Fixed the unsynchronised check-then-act in `postReviewComment` (#96, observed on
4+
`lecture-python-programming.fr#6`: two review comments a second apart, each overwritten by a
5+
different run). Hidden `<!-- action-translation-review -->` marker + delete-older-ids
6+
reconciliation after every write; decision and the convergence argument in
7+
`decisions/D-2026-07-16-single-review-comment.md`. Note the rule is the *mirror* of the one
8+
#96 sketched — deleting **newer** ids doesn't converge, deleting older ones provably does.
9+
10+
Tests (`src/__tests__/reviewer-comment.test.ts`) drive N reviewers against a shared in-memory
11+
comments API; the natural `await` interleaving reproduces the all-list-then-all-create race
12+
without any scheduling tricks. Confirmed the five-run test fails on the pre-fix code (5
13+
comments) before keeping it — 5 of 16 fail against the old implementation.
14+
15+
Found while there: the `[opened, synchronize, labeled, reopened]` trigger list #96 blames on
16+
the target repo is **shipped by our own docs** (`connect-existing.md`), and no review template
17+
had a `concurrency` group — so every edition set up from the tutorials inherits the 5x review
18+
spend, `.fa` included. Added the per-PR `concurrency` group to all four review templates, the
19+
`github.event.label.name` guard to the one that triggers on `labeled`, and explicit
20+
`permissions` (the delete needs `pull-requests: write`). `labeled` itself is load-bearing —
21+
labels are applied after the PR opens, so dropping it would skip reviews entirely.
22+
23+
#promote: docs that ship a workflow template make workflow bugs a *fleet* problem — the
24+
target-repo fix (lecture-python-programming.fr#7) doesn't stop the next edition inheriting it.
25+
Worth checking other QuantEcon action repos whose READMEs carry copy-paste workflows.
26+
27+
Not addressed: #96's secondary observation (four `labeled` events recorded for two
28+
`addLabels` calls, #92 family). Unreproduced, mechanism unknown from the logs; the
29+
`concurrency` group makes it harmless for now, but the naive label retry loop in
30+
`pr-creator.ts` is still the #92-shaped hazard if it ever fires.

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
## [0.16.1] - 2026-07-15
10+
### Fixed
11+
- **Concurrent review runs no longer post duplicate review comments**: `postReviewComment` listed comments, looked for its own, then created one if absent — a check-then-act with nothing making it atomic, so every concurrent run saw "no comment yet" and created one. Observed on `lecture-python-programming.fr#6`, which carried two "Translation Quality Review" comments a second apart, each subsequently overwritten by a *different* run, leaving scores that matched no single review. Review comments now carry a hidden `<!-- action-translation-review -->` marker, and each run deletes any older marked comment after writing its own. Because ids increase with creation time and every run lists after it writes, the run holding the highest id always sees and removes the rest: exactly one comment survives any interleaving. Pre-existing duplicates (from v0.16.1 and earlier, which have no marker) are cleaned up on the next review of that PR.
12+
13+
### Changed
14+
- **Review comments are identified by marker, not prose**: the old predicate matched any comment containing both "Translation Quality Review" and "action-translation" anywhere in its body — it could match, and overwrite, a human comment quoting a review. Matching is now anchored at the start of the body, so quoted or reposted reviews are never touched.
15+
- **Review workflow templates gained a `concurrency` group** (docs): the action-side fix converges the *comment*, but each racing run still pays for a full review. A per-PR `concurrency` group with `cancel-in-progress` collapses the `opened` and `labeled` events a single sync produces into one review. The `connect-existing` template additionally ignores `labeled` events for labels other than `action-translation` — it triggers on `labeled` (necessary, since labels are applied after the PR is opened), and a sync applying two labels was starting a full review per label. Templates now also declare `permissions` explicitly (`pull-requests: write`, required to remove duplicate comments). Existing target repos should copy these guards; without them a sync bills several reviews of the same diff.
1116

1217
### Fixed
1318
- **French typography no longer corrupts footnote/link-reference definitions**: the NBSP pass rewrote `[^id]: text` as `[^id] : text`, which stops the line parsing as a definition — it rendered as literal text and broke every reference (shipped in the fr seed, e.g. `pandas.md`). Definition labels are now masked, and the exact corruption is repaired on contact, so running `scripts/typography/apply.mjs` over an affected repo heals it. The definition text after the colon is still typeset.

dist-action/index.js

Lines changed: 93 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29038,6 +29038,19 @@ var REVIEW_RETRY_CONFIG = {
2903829038
baseDelayMs: 1e3
2903929039
// 1s, 2s, 4s with exponential backoff
2904029040
};
29041+
var REVIEW_COMMENT_MARKER = "<!-- action-translation-review -->";
29042+
var LEGACY_REVIEW_HEADING = /^#{2} .*Translation Quality Review/;
29043+
var REVIEW_COMMENT_UPSERT_ATTEMPTS = 3;
29044+
function isActionReviewComment(body) {
29045+
if (!body)
29046+
return false;
29047+
if (body.startsWith(REVIEW_COMMENT_MARKER))
29048+
return true;
29049+
return LEGACY_REVIEW_HEADING.test(body) && body.includes("action-translation");
29050+
}
29051+
function isNotFoundError(error3) {
29052+
return typeof error3 === "object" && error3 !== null && error3.status === 404;
29053+
}
2904129054
function parseJsonResponse(text) {
2904229055
let parsed;
2904329056
try {
@@ -29771,32 +29784,90 @@ ${diffResult.issues.map((i) => `- ${i}`).join("\n")}`;
2977129784
return comment;
2977229785
}
2977329786
/**
29774-
* Post review comment on PR
29787+
* Our review comments on the PR, oldest first.
29788+
*
29789+
* Paginated: beyond 30 comments the existing one is missed and duplicates accumulate.
29790+
*/
29791+
async listOwnReviewComments(prNumber, owner, repo) {
29792+
const comments = await this.octokit.paginate(this.octokit.rest.issues.listComments, {
29793+
owner,
29794+
repo,
29795+
issue_number: prNumber,
29796+
per_page: 100
29797+
});
29798+
return comments.filter((c) => isActionReviewComment(c.body)).sort((a, b) => a.id - b.id);
29799+
}
29800+
/**
29801+
* Delete our review comments older than `keepId` — duplicates left by concurrent runs.
29802+
*
29803+
* Best effort: a duplicate comment is not worth failing a review that posted successfully.
29804+
*/
29805+
async deleteOlderReviewComments(prNumber, owner, repo, keepId) {
29806+
let duplicates;
29807+
try {
29808+
duplicates = (await this.listOwnReviewComments(prNumber, owner, repo)).filter((c) => c.id < keepId);
29809+
} catch (error3) {
29810+
core2.warning(`Could not check PR #${prNumber} for duplicate review comments: ${error3}`);
29811+
return;
29812+
}
29813+
for (const duplicate of duplicates) {
29814+
try {
29815+
await this.octokit.rest.issues.deleteComment({ owner, repo, comment_id: duplicate.id });
29816+
core2.info(`Removed duplicate review comment ${duplicate.id} on PR #${prNumber}`);
29817+
} catch (error3) {
29818+
if (isNotFoundError(error3))
29819+
continue;
29820+
core2.warning(`Could not remove duplicate review comment ${duplicate.id} on PR #${prNumber}: ${error3}`);
29821+
}
29822+
}
29823+
}
29824+
/**
29825+
* Post the review, leaving exactly one review comment on the PR.
29826+
*
29827+
* Concurrent review runs are routine — one sync fires `opened` plus a `labeled` event per
29828+
* label — and list-then-create is a check-then-act race: every run sees "no comment yet"
29829+
* and creates one (issue #96). Issue comments have no conditional-write primitive, so each
29830+
* run instead reconciles after writing, deleting every *older* review comment of ours.
29831+
* Ids increase with creation time and each run lists after it writes, so the run holding the
29832+
* highest id necessarily sees the others and removes them: one comment survives any
29833+
* interleaving. (Deleting *newer* ids instead would not converge — a run that lists before
29834+
* a slower run creates would leave both.)
2977529835
*/
2977629836
async postReviewComment(prNumber, owner, repo, comment) {
29837+
const body = `${REVIEW_COMMENT_MARKER}
29838+
${comment}`;
2977729839
try {
29778-
const comments = await this.octokit.paginate(this.octokit.rest.issues.listComments, {
29779-
owner,
29780-
repo,
29781-
issue_number: prNumber
29782-
});
29783-
const existingComment = comments.find((c) => c.body?.includes("Translation Quality Review") && c.body?.includes("action-translation"));
29784-
if (existingComment) {
29785-
await this.octokit.rest.issues.updateComment({
29786-
owner,
29787-
repo,
29788-
comment_id: existingComment.id,
29789-
body: comment
29790-
});
29840+
for (let attempt = 1; attempt <= REVIEW_COMMENT_UPSERT_ATTEMPTS; attempt++) {
29841+
const existing = await this.listOwnReviewComments(prNumber, owner, repo);
29842+
if (existing.length === 0) {
29843+
const { data: created } = await this.octokit.rest.issues.createComment({
29844+
owner,
29845+
repo,
29846+
issue_number: prNumber,
29847+
body
29848+
});
29849+
core2.info(`Posted review comment on PR #${prNumber}`);
29850+
await this.deleteOlderReviewComments(prNumber, owner, repo, created.id);
29851+
return;
29852+
}
29853+
const target = existing[existing.length - 1];
29854+
try {
29855+
await this.octokit.rest.issues.updateComment({
29856+
owner,
29857+
repo,
29858+
comment_id: target.id,
29859+
body
29860+
});
29861+
} catch (error3) {
29862+
if (isNotFoundError(error3) && attempt < REVIEW_COMMENT_UPSERT_ATTEMPTS) {
29863+
core2.info(`Review comment ${target.id} was removed by a concurrent run, retrying (${attempt})`);
29864+
continue;
29865+
}
29866+
throw error3;
29867+
}
2979129868
core2.info(`Updated existing review comment on PR #${prNumber}`);
29792-
} else {
29793-
await this.octokit.rest.issues.createComment({
29794-
owner,
29795-
repo,
29796-
issue_number: prNumber,
29797-
body: comment
29798-
});
29799-
core2.info(`Posted review comment on PR #${prNumber}`);
29869+
await this.deleteOlderReviewComments(prNumber, owner, repo, target.id);
29870+
return;
2980029871
}
2980129872
} catch (error3) {
2980229873
core2.error(`Failed to post review comment: ${error3}`);

0 commit comments

Comments
 (0)