v0.6.1 — a failed write is not proof that nothing was written
A 56-comment batch got a 504 after GitHub had already created the review. The blind retry then hit the secondary rate limit precisely because the write had succeeded, the batch read as failed, the per-comment fallback re-posted all 56, and the run reported posted 0 / attempted 56; errors 56 with every comment live. Trusting that number, --resume posted a second copy: 112 comments.
The rule
POSTing a comment is not idempotent, so a 5xx or timeout means unknown, not "nothing written". Everything below follows from that.
- Providers make ONE attempt and throw —
postLineCommentandpostBatchComments, on all three providers.withRetryis for reads and other idempotent calls. Retry lives inrunPost, because onlyrunPostcan reconcile first. - The PR is read back before any decision — before a retry, before the per-comment fallback, and before reporting a count.
- Unknown ≠ empty. A failed read-back returns
null, never an empty map: the outage that 504s a write is the one that fails the read. Treating those as the same turned 3 findings into 15 live comments in review. Onnullthe run reports and stops. - Identity is
file:line:body. Two findings can legitimately carry the same body, so a body-only match promoted a finding that was never posted — which then filledposted.markerand locked--resumeout of recovering it. - Reconciliation is one-way. Errors may be promoted to posted, never the reverse; demoting on a stale read would send the next resume out to write a live comment again.
- Every publish attempt writes
posted.marker, carryingverified. An unverified run fails closed on resume. Gating the write onposted > 0is what left the incident run with no guard at all. --resumere-reads the PR before deduping (on--dry-runtoo), unions rather than overwrites, and adopts only comments matching a finding it would post — so a bystander's comment can never suppress a security finding. A failed re-read aborts a publishing resume.
Also
- Azure DevOps and GitLab now get this fix too. The reconciliation was reachable only through the batch path, which only GitHub implements, so both other providers still duplicated inside
postLineComment.isTransientErroris now a requiredPrProvidermember. - Codex failures are diagnosable:
codex-failure.log(argv, exit, timing, bounded stdout + stderr), the synchronous spawn throw is caught where it can still reach that log, stdinEPIPEno longer takes down the review, and exit 0 with no output is an error rather than a silent "found nothing".
Verification
3 findings through the incident shape: 6 live comments on 0.6.0 → 3 on 0.6.1. Under the compound write-and-read outage: 15 → 3. Tests 246 → 270; every reconciliation test asserts no path leaves two comments at the same location with the same text.
Full notes in CHANGELOG.md.