Skip to content

fix(automerge): anchor the quiet period on the review TRIGGER, not only on elapsed silence - #161

Merged
topcoder1 merged 4 commits into
mainfrom
fix/automerge-quiet-anchor-review-trigger
Aug 15, 2026
Merged

fix(automerge): anchor the quiet period on the review TRIGGER, not only on elapsed silence#161
topcoder1 merged 4 commits into
mainfrom
fix/automerge-quiet-anchor-review-trigger

Conversation

@topcoder1

Copy link
Copy Markdown
Owner

DRAFT — needs manual review and a manual merge. PRs to topcoder1/ci-workflows
are always manual (global CLAUDE.md), and this one changes the gate that decides
whether every fleet PR auto-arms. Do not ready it until the reasoning below has
been checked, particularly the two over-correction guards.

HUMAN_READABLE_SUMMARY: The auto-merge findings gate waits for the review
conversation to go quiet before arming, but it only ever measured silence that had
already elapsed. Readying a draft starts the review lanes and arms the gate in the
same instant — so a PR that sat in draft for two hours looked maximally quiet at the
exact moment three reviewers began speaking, and armed before any of them posted.
This adds the ready_for_review/reopened timestamp as a fourth anchor, so the
ready path waits exactly as long as a push already does.


Why the existing gate did not fire — measured, not assumed

ci-workflows#149 added this gate and it works: it withheld the arm on wxa_vpn#1503.
The question was why it let wxa_vpn#1512 through on 2026-08-14. It is not a blind spot
in the detector, and it is not that the gate skips a path — the gate ran, cleared, and
armed.

From run 31773626968,
step Quiet period + unaddressed findings, job wall time 05:38:10Z → 05:38:29Z (19s)
against a 20-minute window:

Quiet: newest commit/review activity is 7757s old (>= 1200s).
whois-api-llc/wxa_vpn#1512 — no unaddressed findings (last commit 2026-08-14T03:20:37Z)
Enabling auto-merge (squash) — detection: branch=claude/fix-surfshark-zip-ingestion

The PR was drafted at 03:21 and readied at 05:38:04Z. All three anchors — last authored
commit, last inline comment, last non-self bot comment — dated from before it went into
draft, so the window was already satisfied 6.5× over at the instant of readying. The
gate never slept. The detector's "clean" was accurate and useless: it read a conversation
the reviews had not joined yet. Codex posted VERDICT: REGRESSION at 05:39:01Z, 35
seconds after the arm, and the PR merged at 05:42:51Z carrying it. It shipped and needed
follow-up PR wxa_vpn#1518 to fix.

The generator: the gate asks "has anyone spoken recently?" when the question it needs
answered is "have the reviewers this event just triggered finished speaking?" Those differ
by exactly one case — the one where the arming event is the review trigger.
pr-review.yml, pr-codex-review.yml and claude-author-automerge.yml share the same
types: list, so that case is ready_for_review (and reopened), and it fires on every
draft that is readied.

A longer window cannot fix it. No window helps when the clock starts pre-expired.

What the fix is

One more anchor in read_anchor(): the newest ready_for_review / reopened timeline
event, max'd in with the other three.

This is not new policy — the push path already waits. wxa_vpn#1513, the same day,
slept 1162s then 441s and merged clean. This only stops the ready path from skipping a
wait the push path has always taken.

Scope of the behaviour change

Path Before After
push / synchronize waits the window unchanged
draft readied after idling arms immediately waits the window
labeled re-run, long after ready arms immediately unchanged (event has aged out)
PR opened non-draft, never reopened waits the window unchanged (no such event)
docs/tests-only diffs safe-paths-automerge.yml, no gate unchanged — deliberately

The last row is deliberate: docs-only auto-merge is policy and safe-paths-automerge.yml
has no findings gate by design. Nothing here touches it.

Tests — proven to bite, not merely added

New selftest/test_automerge_quiet_anchor.sh (14 checks), executing the run block
extracted from the shipped workflow under gh/date/sleep stubs, same harness as
test_automerge_findings_gate.sh. Three mutations, each caught by the cases meant to
catch it:

Mutation Result
remove the anchor entirely (pre-fix state) 5 fail — S1, S2, A3, A5 and A1 reporting sleeps=[] output=[clear=1 reason=quiet+clean], the #1512 log line exactly
|| return 1|| timeline='[]' (fail open) A5 alone fails
drop the .event == filter (anchor on everything) S2 + all six A4 cases fail

Reverted after each; full suite green. The over-correction guards (A2, A4, A6) pass
under mutation 1 by design — they guard against the fix going too far, not against its
absence, so they must not move when it is removed.

A2 and A4 are the ones worth reviewing hardest. Over-gating is how these controls get
set to findings_quiet_minutes=0, so the anchor is pinned to be the event's timestamp
(not a boolean "was readied", which would hold every later run for a full window) and
pinned to ignore labeled — pr-classify labels every PR within seconds of opening, and
this workflow re-runs on labeled.

Also: test_automerge_findings_gate.sh's S4 loop grew the fourth endpoint. Its claim is
"every anchor read paginates", and a list enumerating three of four silently stops being
that. Its gh stub grew a timeline arm returning [] (the pre-fix anchor set exactly).

Verification run locally: new test 14/14; test_automerge_findings_gate.sh 130 checks,
test_automerge_hold_gate.sh 24, test_automerge_base_gate.sh 30, test_automerge_riskfile_gate.sh
22 — all pass. actionlint and shellcheck -s bash clean.

Two corrections to the incident report, from the run data

Of the five wxa_vpn PRs said to have merged past open findings on 08-14, one is an
auto-arm failure:

  • #1512 — the real one, above.
  • #1518 — same mechanism (cleared at 3074s), but its review posted "No issues found".
    A near miss, not a hit.
  • #1523risk:blocked; the gate refused to arm and said so at 20:01:09Z. The
    21:10:27Z finding was live, and it was merged manually at 21:20:14Z, four minutes
    after the operator's own comment noting bb-unaddressed-findings.sh exits 1. That is
    the manual lane, which this gate never blocks by design.
  • #1510 / #1513 — every bot review was clean at merge time. #1510's findings came from
    an adversarial review posted at 00:38:16Z, after the 00:31:58Z merge. On #1513 the gate
    waited the full 20 minutes (slept 1162s + 441s) and merged clean.

Known defect this PR does NOT fix

unaddressed-findings.sh flags non-bot inline replies that resolve a finding. On
wxa_vpn#1523 the author's five "Fixed in 38774043" replies landed 7–12s after the fix
commit and every one was flagged. Measured on that PR: 5/5 false positives carry
in_reply_to_id; 0/14 genuine bot findings do.
The one-line narrowing is
select(.in_reply_to_id == null or (.user.login | endswith("[bot]"))).

Left out of this PR on purpose. It is pre-existing and not worsened here — inline
comments already anchor the clock, so the gate already waits on such a reply and then
flags it, with or without this change. And the detector is a vendored twin required to
stay byte-identical with topcoder1/dotclaude's
templates/ci-workflows/scripts/bb-unaddressed-findings.sh (verified identical today), so
it needs a paired PR in both repos rather than a rider on this one. Happy to open that pair
next.

🤖 Generated with Claude Code

…ly on elapsed silence

wxa_vpn#1392, incident PR wxa_vpn#1512 (2026-08-14).

The quiet gate held the arm until the conversation had been silent for
findings_quiet_minutes, measured against three anchors — newest authored
commit, newest inline comment, newest non-self [bot] comment. All three
measure silence that has ALREADY ELAPSED, which is the right question
only when the event that woke the workflow did not itself start a review.

`ready_for_review` does exactly that. pr-review.yml, pr-codex-review.yml
and this workflow share the same `types:` list, so readying a draft fires
the review lanes and arms the gate in the same instant, while every
anchor the gate can read still dates from before the PR was drafted. The
longer a PR sits in draft, the more thoroughly the gate is satisfied that
nobody is talking — at the exact moment three review lanes start talking.

Measured on #1512, which was drafted at 03:21 and readied at 05:38:04Z:

  Quiet: newest commit/review activity is 7757s old (>= 1200s).
  whois-api-llc/wxa_vpn#1512 — no unaddressed findings (last commit 03:20:37Z)
  Enabling auto-merge (squash)                                  [05:38:26Z]

It never slept. The detector's "clean" was accurate and useless: it read
a conversation the reviews had not joined yet. Codex posted VERDICT:
REGRESSION at 05:39:01Z — 35s after the arm — and the PR merged at
05:42:51Z carrying it. The regression shipped and needed follow-up PR
#1518 to fix. #1518 then cleared the same way at 3074s; its review came
back clean, so that one was a near miss rather than a hit.

A longer window is not the fix: no window helps when the clock starts
pre-expired, and 7757s already exceeded the 20-minute default by 6.5x.
The anchor has to include the moment the CURRENT round of review was
triggered — so c4 is the newest `ready_for_review`/`reopened` event.

This is not new policy. The push path already waits: wxa_vpn#1513, the
same day, slept 1162s then 441s and merged clean. This only stops the
ready path from skipping a wait the push path has always taken.

Three properties the tests pin, because over-gating is how these controls
get switched off:

  * the anchor is the event's TIMESTAMP, not a boolean "was readied", so
    a later `labeled` re-run sees it age out normally and the gate cannot
    be held shut by its own re-runs;
  * only the two review triggers anchor — `labeled` above all must not,
    since pr-classify labels every PR within seconds of opening and this
    workflow re-runs on `labeled`;
  * a PR with no such event keeps exactly the pre-fix anchor set.

The read fails CLOSED like its three siblings: "could not read the
timeline" and "the PR was never readied" are the same empty value
downstream, and only the second justifies arming.

Tests: selftest/test_automerge_quiet_anchor.sh (14 checks). Proven to
bite by mutation — removing the anchor fails 5 checks with A1 reporting
`sleeps=[] clear=1 reason=quiet+clean`, the #1512 log line exactly;
making the read fail-open fails A5 alone; dropping the event filter fails
S2 and all six A4 cases. test_automerge_findings_gate.sh's S4 loop grew
the fourth endpoint (its claim is "every anchor read paginates", and a
list enumerating three of four silently stops being that) and its gh stub
grew a timeline arm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Coverage Floor — mode: enforce

metric value
measured 100.0%
floor (current) 99.0%
target 100.0%
last bumped 2026-05-12

@topcoder1
topcoder1 marked this pull request as ready for review August 14, 2026 23:46
@cursor

cursor Bot commented Aug 14, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@github-actions github-actions Bot added the risk:blocked Risk class: blocked label Aug 14, 2026
@github-actions

Copy link
Copy Markdown

Risk class: blocked — manual merge required.

This PR touches one of the blocked path categories from .github/risk-paths.yml (Dockerfiles, docker-compose, .github/workflows/**, **/.env*, **/secrets*, infra/, terraform/, k8s/, or the classifier config itself).

Auto-merge is refused by claude-author-automerge.yml. A maintainer should review the diff and click "Squash and merge" themselves.

(This is a policy notice, not a code-quality failure. The classify job itself does not fail — required CI checks remain authoritative for "is the code green.")

…pper list's completeness

test_workflow_guards.py runs the .sh selftests under pytest via an explicit
parametrize list — "what makes them CI-enforced rather than run-manually-only
documentation", per its own docstring. This PR's test_automerge_quiet_anchor.sh
was not in that list: it passed 14/14 on the workstation and ran in CI never,
which is the exact silently-unenforced shape the gate it tests exists to close.

Listing it fixes the instance. The new test_every_shell_selftest_is_listed
fixes the class: every selftest/test_*.sh on disk must be in the list or in
_UNLISTED_OK (named exemptions with reasons; currently only
test_bb_automerge_risk_patterns.sh, which needs a workstation checkout). Stale
exemptions fail too.

The guard bit on its first execution: test_claude_review_max_turns_type.sh
(#152) and test_ruff_format_gate.sh (#160) both shipped unlisted and have never
run in CI. Both pass today; both are now listed. Mutation-proven the other way
with a stray selftest/test_stray_zz.sh (fails naming it), then removed.

45 tests pass (was 42 — the three newly-wired selftests now run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
printf '%s\n%s\n%s\n' "$c1" "$c2" "$c3" | grep -v '^$' | sort | tail -1
timeline=$(retry gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/timeline?per_page=100" --paginate) || return 1
c4=$(printf '%s' "$timeline" | jq -rs '[(add // [])[]
| select(.event == "ready_for_review" or .event == "reopened")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opened event is a review trigger not anchored by c4

pr-review.yml and pr-codex-review.yml trigger on types: [opened, synchronize, reopened, ready_for_review]. The automerge gate fires on the same set of events. When a PR is opened (not as a draft), the review lanes start in the same instant — but opened is not in this filter, so c4 is empty and the gate falls back to c1 (commit date).

If a developer pushes commits to a branch at T0, then opens the PR at T1 > T0 + 20m, the gate sees c1 as old, skips the quiet wait, and arms before any review lands — the exact incident shape of wxa_vpn#1512 replayed on the opened path.

The PR comment says "A PR opened non-draft and never reopened has no such event, max yields empty, and the anchor set is exactly what it is today", which acknowledges the gap. But opened fires the same three review lanes as ready_for_review, so the justification for anchoring on ready_for_review applies equally here. Consider also selecting .event == "opened", or documenting why the commit-date anchor is considered sufficient for the opened path.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 440d61e — c5 (PR creation time, from the event payload) anchors the opened trigger with A7/A8/S3 pinning it (mutation: dropping c5 fails A7 with sleeps=[] clear=1, the incident shape); the immutable timeline read is hoisted out of the wait loop with its own fail-closed error; the in-loop anchor error names exactly the reads it covers; A3 asserts clear=1; the date stub's unmapped-timestamp error now says where to add the mapping.

Comment thread selftest/test_automerge_quiet_anchor.sh Outdated
new_case
timeline_with reopened 2026-01-01T02:00:00Z
exec_gate 20
if [ "$RC" -eq 0 ] && grep -q '^1000$' "$CASE/sleep.log"; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A3 checks the sleep but not the arm — a regression in reopened would pass undetected

A1 (the analogous case for ready_for_review) asserts all three: RC -eq 0, clear=1 in GITHUB_OUTPUT, and sleep.log contains 1000. A3 only asserts RC -eq 0 and sleep.log. If a bug causes the gate to sleep the correct 1000 s but then write clear=0 (e.g., the findings-check path misbehaves for the reopened case), A3 passes while the gate is broken.

Suggested change
if [ "$RC" -eq 0 ] && grep -q '^1000$' "$CASE/sleep.log"; then
if [ "$RC" -eq 0 ] && grep -q '^clear=1$' "$CASE/output" && grep -q '^1000$' "$CASE/sleep.log"; then

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 440d61e — c5 (PR creation time, from the event payload) anchors the opened trigger with A7/A8/S3 pinning it (mutation: dropping c5 fails A7 with sleeps=[] clear=1, the incident shape); the immutable timeline read is hoisted out of the wait loop with its own fail-closed error; the in-loop anchor error names exactly the reads it covers; A3 asserts clear=1; the date stub's unmapped-timestamp error now says where to add the mapping.

c4=$(printf '%s' "$timeline" | jq -rs '[(add // [])[]
| select(.event == "ready_for_review" or .event == "reopened")
| .created_at] | max // empty') || return 1
printf '%s\n%s\n%s\n%s\n' "$c1" "$c2" "$c3" "$c4" | grep -v '^$' | sort | tail -1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale error message 7 lines below: "commits/comments" now omits timeline

read_anchor now makes four API calls (commits, inline comments, issue comments, timeline). When the timeline read fails, the error at line 1168 still says (commits/comments) — steering an on-call engineer toward the wrong endpoints. The message needs to include timeline:

::error::could not read the quiet-period anchors (commits/comments/timeline) after retries — refusing to arm auto-merge (fail closed).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 440d61e — c5 (PR creation time, from the event payload) anchors the opened trigger with A7/A8/S3 pinning it (mutation: dropping c5 fails A7 with sleeps=[] clear=1, the incident shape); the immutable timeline read is hoisted out of the wait loop with its own fail-closed error; the in-loop anchor error names exactly the reads it covers; A3 asserts clear=1; the date stub's unmapped-timestamp error now says where to add the mapping.

| select(((.body // "") | test("<!-- (claude-author-automerge|pr-classify)")) | not)
| .created_at] | max // empty') || return 1
printf '%s\n%s\n%s\n' "$c1" "$c2" "$c3" | grep -v '^$' | sort | tail -1
timeline=$(retry gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/timeline?per_page=100" --paginate) || return 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timeline API re-fetched on every loop iteration, but ready_for_review is immutable

c1 (commits), c2 (inline review comments), and c3 (bot comments) can all change between iterations — a new push, a new review — so re-reading them makes sense. c4 (ready_for_review/reopened events) cannot change: historical PR timeline events are immutable.

For a PR with a long timeline (many labels, reviews, assignments over months), --paginate on the timeline endpoint can require many HTTP requests. This cost is paid on every sleep/wake cycle. Consider reading the timeline once before the loop and reusing the result:

local c4_cached
c4_cached=$(read_c4) || return 1  # single read before loop starts

The quiet-cap design (cap = 3 * QUIET_S) means the loop can run multiple times for very active PRs, so the extra round-trips are not purely theoretical.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 440d61e — c5 (PR creation time, from the event payload) anchors the opened trigger with A7/A8/S3 pinning it (mutation: dropping c5 fails A7 with sleeps=[] clear=1, the incident shape); the immutable timeline read is hoisted out of the wait loop with its own fail-closed error; the in-loop anchor error names exactly the reads it covers; A3 asserts clear=1; the date stub's unmapped-timestamp error now says where to add the mapping.

printf '%s\n%s\n%s\n' "$c1" "$c2" "$c3" | grep -v '^$' | sort | tail -1
timeline=$(retry gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/timeline?per_page=100" --paginate) || return 1
c4=$(printf '%s' "$timeline" | jq -rs '[(add // [])[]
| select(.event == "ready_for_review" or .event == "reopened")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (PLAUSIBLE): opened trigger has the same pre-fix vulnerability — the fix is incomplete

The c4 filter covers ready_for_review and reopened but not opened. The caller's types: list is [opened, synchronize, reopened, ready_for_review], so opened fires both the review lanes AND this gate simultaneously — exactly the triggering condition that caused wxa_vpn#1512.

Concrete failure scenario: A developer pushes commits to claude/fix-something at 14:00, then opens the PR at 16:30 (2.5 hours later, a common pattern when commits are staged before PR creation). At 16:30 the gate fires on opened, reads c1 = 14:00 (9000s ago, >> 1200s), c4 is empty (GitHub's timeline API has no opened event type — opened is a PR-creation event, not a timeline event), and the gate arms immediately while pr-review.yml and pr-codex-review.yml are just starting their runs.

The PR comment says "exactly what it is today" for this path, but gives no reason why opened is safer than ready_for_review. It isn't: the mechanism is identical, and the incident (old commits + simultaneous arm + reviews not yet posted) can recur.

Possible fix: Before the while loop, separately read the PR's created_at field via gh api repos/.../pulls/${PR} --jq .created_at and include it in the anchor set only when GITHUB_EVENT_NAME == pull_request && GITHUB_EVENT_ACTION == opened. This requires a GITHUB_EVENT_ACTION env var in the step's env: block, but the approach is the same point-in-time anchor applied here for the other two triggers.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 440d61e — c5 (PR creation time, from the event payload) anchors the opened trigger with A7/A8/S3 pinning it (mutation: dropping c5 fails A7 with sleeps=[] clear=1, the incident shape); the immutable timeline read is hoisted out of the wait loop with its own fail-closed error; the in-loop anchor error names exactly the reads it covers; A3 asserts clear=1; the date stub's unmapped-timestamp error now says where to add the mapping.

Comment thread selftest/test_automerge_quiet_anchor.sh Outdated
case "$3" in
2026-01-01T00:00:00Z) echo 1000000 ;;
2026-01-01T02:00:00Z) echo 1006800 ;;
*) echo "date-stub: unmapped timestamp '$3'" >&2; exit 1 ;;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

date stub exits 1 for any timestamp not in the two-entry map — cryptic failure for future test cases

The stub maps only 2026-01-01T00:00:00Z (OLD) and 2026-01-01T02:00:00Z (YOUNG). All six current cases happen to produce an anchor that is one of these two values, so this is not triggered today.

If a future case introduces a third fixture timestamp (e.g., a CHATTY scenario analogous to the one in test_automerge_findings_gate.sh), the stub exits 1, and the arithmetic expansion $(date -u -d "$anchor" +%s) fails inside age=$(( ... )). With set -euo pipefail, the gate script aborts and RC is non-zero — but the failure manifests as date-stub: unmapped timestamp '...' in stdout rather than a recognisable assertion failure.

test_automerge_findings_gate.sh's date stub avoids this by adding a third dynamic entry:

2026-01-01T09:09:09Z) echo $(( $(cat "$T_NOW") - 100 )) ;;

Consider mirroring that pattern, or documenting the two-entry constraint as intentional so the next author knows to extend the stub before adding a new fixture timestamp.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 440d61e — c5 (PR creation time, from the event payload) anchors the opened trigger with A7/A8/S3 pinning it (mutation: dropping c5 fails A7 with sleeps=[] clear=1, the incident shape); the immutable timeline read is hoisted out of the wait loop with its own fail-closed error; the in-loop anchor error names exactly the reads it covers; A3 asserts clear=1; the date stub's unmapped-timestamp error now says where to add the mapping.

c4=$(printf '%s' "$timeline" | jq -rs '[(add // [])[]
| select(.event == "ready_for_review" or .event == "reopened")
| .created_at] | max // empty') || return 1
printf '%s\n%s\n%s\n%s\n' "$c1" "$c2" "$c3" "$c4" | grep -v '^$' | sort | tail -1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (CONFIRMED): Error message 7 lines below still says "commits/comments" — misleading when the timeline read fails

At line 1168 (unchanged), the error message printed when read_anchor fails still reads:

could not read the quiet-period anchors (commits/comments) after retries

read_anchor now makes four API calls. When the timeline call at line 1157 fails (e.g., transient API error or rate-limit on /issues/{n}/timeline), this message tells operators to inspect the commits/comments endpoints — which may be perfectly healthy. An incident responder following this error would look in the wrong place.

Fix: update line 1168 to read (commits/comments/timeline) or just (all four anchor reads).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 440d61e — c5 (PR creation time, from the event payload) anchors the opened trigger with A7/A8/S3 pinning it (mutation: dropping c5 fails A7 with sleeps=[] clear=1, the incident shape); the immutable timeline read is hoisted out of the wait loop with its own fail-closed error; the in-loop anchor error names exactly the reads it covers; A3 asserts clear=1; the date stub's unmapped-timestamp error now says where to add the mapping.

Comment thread selftest/test_automerge_quiet_anchor.sh Outdated
new_case
timeline_with reopened 2026-01-01T02:00:00Z
exec_gate 20
if [ "$RC" -eq 0 ] && grep -q '^1000$' "$CASE/sleep.log"; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (PLAUSIBLE): A3 missing clear=1 check — test would pass even if reopened sleeps but gate fails to arm

A1 (the ready_for_review case) checks three conditions: RC=0, clear=1 in GITHUB_OUTPUT, and sleep.log contains 1000. A3 only checks two: RC=0 and sleep.log contains 1000.

Concrete failure scenario: A regression changes the gate so that when the anchor is a reopened event, the findings-check stub path writes clear=0 instead of clear=1 (e.g., wrong branch taken after the quiet period). The gate sleeps 1000s correctly (so sleep.log has 1000) and exits 0 (so RC=0). A3 passes. The actual arming behavior is broken but CI is green.

Suggested change
if [ "$RC" -eq 0 ] && grep -q '^1000$' "$CASE/sleep.log"; then
if [ "$RC" -eq 0 ] && grep -q '^clear=1$' "$CASE/output" && grep -q '^1000$' "$CASE/sleep.log"; then

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 440d61e — c5 (PR creation time, from the event payload) anchors the opened trigger with A7/A8/S3 pinning it (mutation: dropping c5 fails A7 with sleeps=[] clear=1, the incident shape); the immutable timeline read is hoisted out of the wait loop with its own fail-closed error; the in-loop anchor error names exactly the reads it covers; A3 asserts clear=1; the date stub's unmapped-timestamp error now says where to add the mapping.

| select(((.body // "") | test("<!-- (claude-author-automerge|pr-classify)")) | not)
| .created_at] | max // empty') || return 1
printf '%s\n%s\n%s\n' "$c1" "$c2" "$c3" | grep -v '^$' | sort | tail -1
timeline=$(retry gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/timeline?per_page=100" --paginate) || return 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Efficiency (PLAUSIBLE): c4 is immutable but re-fetched on every loop iteration

c1, c2, and c3 must be re-read on each iteration because new commits or review comments can arrive while the gate is sleeping. c4 cannot: ready_for_review and reopened are point-in-time historical events; once a PR is readied it cannot un-ready itself to change c4's value.

For a PR with a long event history (hundreds of labels, reviews, assignments), --paginate on /issues/{n}/timeline makes multiple HTTP calls. At the 3× quiet cap, the loop can iterate many times with short remaining periods, multiplying those calls proportionally.

Fix: compute c4 once before the loop and include it as a fixed offset in read_anchor's final printf:

c4_fixed=$(…timeline jq…) || exit 1   # before the while :; do
read_anchor() {
  …
  printf '%s\n%s\n%s\n%s\n' "$c1" "$c2" "$c3" "$c4_fixed" | grep -v '^$' | sort | tail -1
}

This eliminates all redundant timeline fetches without changing behavior, since the value can never differ between iterations.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 440d61e — c5 (PR creation time, from the event payload) anchors the opened trigger with A7/A8/S3 pinning it (mutation: dropping c5 fails A7 with sleeps=[] clear=1, the incident shape); the immutable timeline read is hoisted out of the wait loop with its own fail-closed error; the in-loop anchor error names exactly the reads it covers; A3 asserts clear=1; the date stub's unmapped-timestamp error now says where to add the mapping.

Comment thread selftest/test_automerge_quiet_anchor.sh Outdated
case "$3" in
2026-01-01T00:00:00Z) echo 1000000 ;;
2026-01-01T02:00:00Z) echo 1006800 ;;
*) echo "date-stub: unmapped timestamp '$3'" >&2; exit 1 ;;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test fragility (PLAUSIBLE): date stub only maps 2 timestamps — cryptic failure if new fixtures are added

The sibling file test_automerge_findings_gate.sh maps three timestamps in its date -u -d stub (it also handles 2026-01-01T09:09:09Z for its livelock/CHATTY case). This file maps only two, with a hard exit 1 for anything else.

Concrete failure scenario: A future contributor adds a CHATTY-style test case here (e.g. to pin the quiet-cap behaviour), uses a new timestamp like 2026-01-01T06:00:00Z, and the stub exits 1 inside age=$(( $(date -u +%s) - $(date -u -d "$anchor" +%s) )). With set -euo pipefail, the gate script aborts with a bash arithmetic expansion error — no line, no failed=1, just a confusing non-zero RC and an error message pointing at date-stub: unmapped timestamp. The failing assertion appears to be gate logic; the real cause is a missing stub mapping.

The companion test already has the fix: map timestamps dynamically via echo $(( $(cat "$T_NOW") - N )) for relative values, or add a *) date -u -d "$3" +%s ;; fallback to the real date binary for unmapped values.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 440d61e — c5 (PR creation time, from the event payload) anchors the opened trigger with A7/A8/S3 pinning it (mutation: dropping c5 fails A7 with sleeps=[] clear=1, the incident shape); the immutable timeline read is hoisted out of the wait loop with its own fail-closed error; the in-loop anchor error names exactly the reads it covers; A3 asserts clear=1; the date stub's unmapped-timestamp error now says where to add the mapping.

@claude

claude Bot commented Aug 15, 2026

Copy link
Copy Markdown

Flagged 5 issues inline — see comments. Most significant: the opened event (also in review callers' types: list) is not anchored by c4, leaving the same pre-fix vulnerability for non-draft PRs opened with old branch commits. Also confirmed: stale error message at line 1168 still says "commits/comments" after the timeline read was added as a fourth anchor.

…5); hoist immutable reads; test tightening

Five findings from #161's Claude Review, all addressed:

1. THE REAL ONE: `opened` is in the same review-lane `types:` list, so a
   branch whose commits idled before the PR was opened NON-draft arrives
   with c1 pre-aged and armed at the instant the reviews started —
   wxa_vpn#1512's hole through a different door, which c4 (ready/reopen
   events) cannot see because a never-drafted PR has neither event. c5 is
   the PR's own creation time, taken from the event payload (zero API
   reads; immutable, so a later labeled re-run sees it aged out). A7 pins
   the door (mutation: dropping c5 from the anchor set fails A7 with
   `sleeps=[] clear=1` — the incident shape); A8 pins that an aged-out
   creation cannot hold the gate shut; S3 pins the env binding, because the
   run block is ${{ }}-free and an unbound var is an empty c5 forever.

2. Immutable anchors hoisted out of the wait loop: c4/c5 cannot change
   while the run sleeps (a fresh ready_for_review event's own run replaces
   this one via the callers' cancel-in-progress groups), so the timeline is
   read once instead of per wake — on a long timeline --paginate cost
   multiplied per iteration. Each pre-loop read fails closed with its own
   named error.

3. The in-loop anchor error no longer says "commits/comments" while the
   timeline read lives elsewhere — each failure names exactly the reads it
   covers.

4. A3 now asserts clear=1 like A1 — it previously passed a gate that slept
   correctly and then failed to arm.

5. The date stub's unmapped-timestamp error now tells a future contributor
   where to add the mapping instead of failing cryptically inside an
   arithmetic expansion.

All 17 checks pass; actionlint + shellcheck clean; sibling selftests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 15, 2026

Copy link
Copy Markdown

Reviewed. No inline comments posted (no 80 %+ confirmed bugs), but two latent issues worth noting:

  1. exec_gate in test_automerge_findings_gate.sh (line 228) inherits PR_CREATED_AT from the caller's environment — unlike the sibling exec_gate in test_automerge_quiet_anchor.sh which explicitly passes PR_CREATED_AT="${2:-}" to override any ambient value. If a developer's shell has PR_CREATED_AT exported to a recent timestamp, the c5 anchor activates and tests that assert zero sleeps (e.g. B7) will fail with unexpected sleep calls. Fix: add PR_CREATED_AT="" to the env block in test_automerge_findings_gate.sh's exec_gate.

  2. jq … | max // empty is not fail-closed when .created_at is null (c4 assignment, workflow line ~1114). If GitHub returns a ready_for_review timeline event with created_at: null, jq emits no output and exits 0; the || { exit 1; } guard does not fire; c4 is silently empty — restoring the pre-fix behaviour for that run. Extremely unlikely in practice, but the comment explicitly promises fail-closed semantics for an unreadable anchor.

…name the undatable-event boundary — round-2 advisories

The sibling harness inherits ambient env: a developer shell exporting
PR_CREATED_AT would activate c5 in tests that assert zero sleeps. Pinned
empty (proven by running the suite WITH a hostile ambient value exported).
And the fail-closed comment now says what it covers — readability
(fetch+parse) — and that a readable event with created_at:null is
UNDATABLE and contributes no anchor: c4 carries a timestamp, not an
existence claim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@topcoder1

Copy link
Copy Markdown
Owner Author

Both round-2 advisories addressed in c232c68: findings_gate's exec_gate now pins PR_CREATED_AT="" (proven by running that suite with a hostile ambient export — green), and the fail-closed comment now states its boundary precisely: it covers READABILITY (fetch + parse); a readable event carrying created_at: null is undatable and contributes no anchor, because c4 carries a timestamp, not an existence claim.

@claude

claude Bot commented Aug 15, 2026

Copy link
Copy Markdown

No issues found. Logic, null handling, jq expressions, Bash scoping, and test coverage all checked out clean.

@topcoder1
topcoder1 merged commit 44c1f39 into main Aug 15, 2026
16 checks passed
@topcoder1
topcoder1 deleted the fix/automerge-quiet-anchor-review-trigger branch August 15, 2026 17:27
topcoder1 added a commit that referenced this pull request Aug 15, 2026
wxa_vpn#1392's false-positive half, measured on wxa_vpn#1523: the report's top
three hits were the PR's own "Fixed in 38774043" inline replies, posted 7-12s
after the fix commit they cited. A reply that RESOLVES a finding necessarily
post-dates the commit it cites — the author pushes, then replies citing the
push — so every resolution round tripped the newer-than-last-commit signal and
the report re-flagged resolved threads forever. Across that PR: 5/5 false
positives carried in_reply_to_id; 0/14 genuine bot findings did.

The narrowing drops inline comments that are BOTH a reply AND human:

  select(.in_reply_to_id == null or ((.user.login // "") | endswith("[bot]")))

Untouched on purpose: human TOP-LEVEL inline comments still count (a human's
own comment on the diff is a deliberate finding — author-agnosticism survives),
and BOT replies still count (a reviewer bot answering a thread with "still
broken after the fix" is a live finding; keeping them cost zero false positives
in the measured corpus). Accepted residual: a human typing a NEW finding into
an existing thread is skipped — the operator wrote it, so the operator knows it.

This matters beyond the operator CLI because the automerge quiet gate runs this
detector and quotes its report verbatim into auto-merge-withheld comments — a
false flag there declines arms on clean PRs, and a gate that blocks passing PRs
gets switched off.

Vendored-twin discipline: the same edit ships to dotclaude's
templates/ci-workflows/scripts/bb-unaddressed-findings.sh in a paired PR;
byte-identity below the header block verified both ways.

Tests: selftest/test_findings_reply_narrowing.sh (6 checks), CI-wired via
_SHELL_SELFTESTS. Proven to bite by mutation: removing the narrowing fails
R1+R4 (the #1523 shape, rc=1 where 0 expected); dropping the bot escape fails
R2; over-correcting to bots-only inline fails R3+R5. Restored, all green;
shellcheck clean on both files.

Stacked on fix/automerge-quiet-anchor-review-trigger (#161) because both PRs
touch _SHELL_SELFTESTS — review only the top commit until #161 merges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:blocked Risk class: blocked

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant