Skip to content

fix(pr-merge): pin the head before a native arm so an immediate enqueue reads back - #9576

Merged
kyle-sexton merged 4 commits into
mainfrom
fix/postmerge-native-immediate-enqueue
Oct 24, 2026
Merged

kyle-sexton merged 4 commits into
mainfrom
fix/postmerge-native-immediate-enqueue

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Refs: #9508

Summary

Arming a lanes App pull request that is already mergeable enqueues it at once. GitHub then records only an AddedToMergeQueueEvent by melodic-automation-lanes, with no auto-merge event (#9554, armed at 03:29:53Z). The post-merge check read that as a gate merge and waited on a pin nobody posted. After merged_at + DISPATCH_DEADLINE it would file a false read-back demotion and stop the pr-merge lane. This PR does two things:

  1. Going forward, enable-auto-merge pins the head before it arms, so such a merge reads back against a pin.
  2. Retroactively, the post-merge check classifies a merge of feat(performance): add an optional holdout harness to performance:climb, off by default #9554's exact shape as native, so feat(performance): add an optional holdout harness to performance:climb, off by default #9554 clears without a post-hoc pin.

Fix

  • Pin before arm (.github/scripts/pr-merge/enable-auto-merge.mjs): just before ARM_MUTATION, the script posts <!-- babysit-merge-pin sha=<headRefOid> -->, the format of babysit_postmerge.PIN_RE, as the lanes App.

    • It skips the post when the App's newest pin already names that head and is unedited, which is the read-back's own rule. DETAIL_QUERY now reads lastEditedAt for this.
    • If the pin fails to post, the pull request is not armed and the run exits 1 (fail closed).
    • Report-only posts nothing.
    • A later push changes the head, so the read-back fails, which is the safe direction.
  • Immediate-enqueue native (native_auto_merge): with no auto-merge event, a merge is native only when all of these hold:

    • the identity opened the pull request from a same-repository branch;
    • every AddedToMergeQueueEvent has the identity as actor, and an enqueuer that is null or the identity;
    • the read holds the whole enqueue history (hasPreviousPage false);
    • merge.lane-prs is native at the merge commit's first parent.

    An unreadable config at that parent keeps the pin required (fail closed) and leaves the verdict readable. A missing parent is read as "", which read_config_at refuses, so the separate guard for it was removed. Even when the merge is native, an unedited identity pin that stands is read back: a match verifies, and a mismatch is a read-back failure. The enable-event native path is unchanged. MERGED_QUERY is extended in place: mergeCommit.parents(first:1), plus last:10 and pageInfo on the enqueue connection. The config is read only for this shape, once per parent.

  • Residual, stated in the docstring and in reference/safety.md: only a merge of this shape with no unedited pin skips the read-back. With pin-before-arm in place, that is a merge armed before this PR (feat(performance): add an optional holdout harness to performance:climb, off by default #9554), or one whose pin was edited or deleted after the enqueue. Such a merge still needs a green dispatch run and a green canary run.

Verification

  • node --test .github/scripts/pr-merge/*.test.mjs: 301 tests pass. The new tests cover:
    • the pin posted before the arm, in the exact format;
    • no duplicate pin for the same head;
    • a re-pin for another head, an edited pin, or another author's pin;
    • no arm when the pin fails;
    • no pin under report-only.
  • Each pin test was checked by mutation: disabling the pin post fails 3 tests, and forcing pinned() to false fails the no-duplicate test.
  • Post-merge suite: 276 tests in test_babysit_postmerge.py, OK. There is one test per condition: author, cross-repo, auto-merge event, enqueue actor, enqueuer, fix(planning): brief TLDR reads "1 question" for a single question #9540-style earlier human enqueue, hasPreviousPage, no parent, lane-prs gate at the parent, a value other than native or gate, native only at the merge commit (gate at the parent), a gate enqueue under gate reading its pin back, an unreadable config, and an arm pinned before an immediate enqueue verifying with nothing pending.
  • A mutation script removed each guard on its own and re-ran the suite, and each removal fails its own test. The "no merge parent" guard was the exception, because the empty-SHA read already fails closed, so it was deleted; its test also asserts that no contents read is sent.
  • Pin read-back on a native immediate enqueue: with the source at 81ef831, the pin-at-another-head test fails, and so does the no-pin test, which asserts that the pin is read. The pin-at-merged-head test already passed there, because that merge verified without a read.
  • scripts/check-affected.sh --uncommitted: 11 passed. scripts/run-ruff.sh check passes.
  • Live, read-only: the query classifies feat(performance): add an optional holdout harness to performance:climb, off by default #9554 as native (its parent d90fa9db reads lane-prs: native), while fix: feat(discovery,implementation,plugin-quality): add a next: field to the subagent return contract #9489 and docs(harness-ops): add harness-ops:prerequisites row to README skills table #9475 (parent config gate) stay pin-required.

Related

🤖 Generated with Claude Code

…e lane merge

Arming a lanes App pull request that is already mergeable enqueues it at
once, and GitHub records only an AddedToMergeQueueEvent by the identity,
no AutoMergeEnabledEvent (#9554). native_auto_merge read that as a gate
merge, so the post-merge check waited on a pin the gate never posts and
would file a read-back demotion after six hours.

It is now native when there is no auto-merge event, every enqueue in the
history is the identity's, and merge.lane-prs was native at the merge
commit's first parent. The merged read gains the parent and up to ten
enqueue events in place; the config is read only for that shape, once
per parent, and an unreadable config keeps the pin read-back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review 🔄 Running since 2026-10-24T04:41:40.069270Z cafb603 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

claude-security-review reviewed 1a2f7934afec34f13089cfca556276c7545aaaf6: 0 finding(s), 0 as inline comments.

No security issues found in the incremental change since e52f3fd. It touches babysit_postmerge.py and test_babysit_postmerge.py.

What changed: _immediate_enqueue no longer starts with if not merge.queued or lane_prs_at is None. It checks only lane_prs_at is None. It then reads the timeline's own nodes and returns False when that list is empty.

Why it holds:

  • It narrows the native classification, which is the fail-closed direction.
  • A merge with no recorded enqueue can no longer pass the per-enqueue loop (actor/enqueuer is the identity) and reach the merge.lane-prs == native check. That would have skipped the pin read-back with no evidence that the identity enqueued the pull request.
  • The empty-list guard is stricter than the merge.queued check it replaces, because it reads the same timeline the loop reads. The other guards are unchanged: hasPreviousPage must be False, and the actor and enqueuer checks, the first-parent read and the author and same-repository checks all still apply.
  • The new test test_no_recorded_enqueue_needs_the_pin covers the empty-nodes case.

manifest fields: not hit. This incremental diff has no plugin manifest or instruction-surface change. The Actions and workflow surfaces are untouched in this range.

critical-findings: 0

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

claude-review reviewed 1a2f7934afec34f13089cfca556276c7545aaaf6: 0 finding(s), 0 as inline comments.

Incremental review of babysit_postmerge.py and test_babysit_postmerge.py since e52f3fd. I found nothing to report.

What changed. _immediate_enqueue no longer reads merge.queued. It now returns False when the PR's timelineItems.nodes is empty. I checked this against derive_auto_merge in babysit_merge_policy.py:2878, which sets queued=bool(events) from the same timelineItems.nodes. The two guards agree, so the change doesn't alter behaviour. It does stop _immediate_enqueue from relying on a flag set elsewhere, so an empty timeline can no longer pass the "every enqueue is the identity's" loop on its own. The docstring now says "at least one enqueue", which matches the code.

Tests. The new test_no_recorded_enqueue_needs_the_pin builds a PR with an empty timeline, hasPreviousPage False and mergedBy set to the identity. It expects the pin to be required. The test would fail if the empty-nodes guard were removed.

I only read the code and the diff. I did not run the tests. The closing-issue and manifest checks don't apply to this change. No hold class applies.

…ue reads back

enable-auto-merge posts the gate's babysit-merge-pin comment for the head
before it arms, unless the lanes App's newest pin already names that
head unedited; a pin that fails to post leaves the pull request unarmed
and fails the run. Arming an already-mergeable pull request enqueues it
at once with no auto-merge event, so the post-merge check now reads that
head back against a pin, and a later push fails the read-back.

The immediate-enqueue native tests are split one condition per test, and
the safety reference states both rules and the residual.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kyle-sexton kyle-sexton changed the title fix(source-control): count an immediate-enqueue native arm as a native lane merge fix(pr-merge): pin the head before a native arm so an immediate enqueue reads back Oct 24, 2026
kyle-sexton and others added 2 commits October 24, 2026 00:55
… native merge

An immediate-enqueue native merge now reads the identity's unedited pin
when one stands: a match verifies and a mismatch is a read-back failure,
as on the pin-required path. Only a merge with no unedited pin skips the
read-back, which pin-before-arm leaves to merges armed before it, such
as #9554. The enable-event native path is unchanged.

The no-merge-parent guard is removed: a missing parent reads as "",
which read_config_at refuses, so it is not native either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eue native merge

_immediate_enqueue now refuses a timeline with no AddedToMergeQueueEvent
before its actor loop, so a lagging or unrecorded enqueue never reads as
native by an empty loop. The guard replaces the indirect `merge.queued`
check, which derive_auto_merge sets from the same nodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kyle-sexton
kyle-sexton enabled auto-merge October 24, 2026 05:02
@kyle-sexton
kyle-sexton added this pull request to the merge queue Oct 24, 2026
Merged via the queue into main with commit 37bcfd3 Oct 24, 2026
33 checks passed
@kyle-sexton
kyle-sexton deleted the fix/postmerge-native-immediate-enqueue branch October 24, 2026 05:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant