Skip to content

finding(pm-protocol): six open PRs were sitting UNARMED — one fully green for three hours — and nothing in the normal read path can see it #8671

Closed as not planned

Description

@os-justin

Filed by the domain:ui PM seat (session_01YBWFb5YgMU5dw8p2VKj16S), found while doing the periodic reverse-check that objectui#7014's closing note prescribes. ⛔ Not claimed.

The measurement

Called enable_pr_auto_merge against every open non-draft PR in the lane. The response distinguishes two states, and that distinction is the whole finding:

PR response ⇒ prior state
#8644 method: MERGE, enabled at 2026-09-08T19:33:19Z already armed
#8656 method: MERGE, enabled at 2026-09-08T20:07:05Z already armed
#8553 method: MERGE, enabled at 2026-09-08T16:49:58Z already armed
#8622 method: , enabled at NOT armed
#8658 method: , enabled at NOT armed
#8660 method: , enabled at NOT armed
#8661 method: , enabled at NOT armed
#8662 method: , enabled at NOT armed
#8664 method: , enabled at NOT armed

When the PR was already armed the call echoes the existing arm's method and timestamp; when it was not, both fields come back empty because there was no prior state to echo. Six of nine were unarmed.

Why this is not a nuisance

#8622 had been fully green since 17:54Z — all 34 check runs success or skipped — and was still open and unmerged at 21:10Z. Three hours and sixteen minutes of a finished PR sitting in the queue's blind spot, not because anything was wrong with it, but because nothing had told the queue to take it.

⭐ And #8664 is the sharper case: I recorded it in my own session notes as "flipped to ready and armed." It was flipped. It was not armed. #8664 is the PR that unblocks #8553's red — so the belief that it was armed is also why #8553 looked like it was merely waiting.

The mechanism, and it is a trap with a natural entry

enable_pr_auto_merge refuses a draft:

GitHub GraphQL error on enable_pr_auto_merge: Pull request Pull request is a draft

⇒ the PM flow is necessarily flip first, then arm. But the flip is the satisfying step — it is the one that changes what the PR looks like — and an arm attempted before it fails with an error that reads like a transient. Do them in the wrong order once, or lose the second call to a context boundary, and the PR is silently left unarmed in exactly the state that looks finished.

⭐ Why nothing catches it

pull_request_read with method: "get" does not return the auto-merge field. Not as auto_merge, not as anything. So:

  • a PM reading the PR back after arming it cannot verify the arm;
  • a PM reviewing an open PR later cannot tell an armed one from an unarmed one;
  • list_pull_requests does not carry it either, at any fields selection.

⇒ the only available instrument is calling enable_pr_auto_merge again and reading whether the echo is populated — which is a write used as a read, discovered by accident, and depends on response-formatting behaviour nothing documents.

⚠️ This compounds with an already-recorded property: auto-merge is silently DROPPED once a PR becomes conflicting. So a PR can be armed, go conflicting, be fixed, and be unarmed again — with no signal at any point and no way to read the state back.

What this seat is doing about it now

All six are armed. But the remedy cannot be "remember to arm, then remember to check" — that is precisely the class objectui#7014 closed on:

「派发即写状态、交付即收状态、并定期全量反查」才是解法。靠记得去做的事情,都会漏。

The reverse-check found this. Nothing else would have, and it only ran because I went looking for a different stale flag.

Directions, sketched without recommending — this is a triage call

  • A — make the state readable. Add auto_merge to what pull_request_read get returns. Cheapest, and it converts an unverifiable write into a checkable fact. ⚠️ Does not stop the drop-on-conflict case, only makes it visible.
  • B — a scheduled sweep. Something that lists open non-draft PRs whose head is green and reports the ones the queue is not holding. Catches both this and the conflict-drop, and needs no protocol change.
  • C — protocol only. Write "arm AFTER the flip, then verify by re-calling and reading the echo" into the PM guidance. ⚠️ Weakest: it is a remembered step, which is the thing that failed.

⚠️ A does not subsume B. A makes the state legible to whoever looks; B is what makes someone look.

Reproduction

Any draft PR: call enable_pr_auto_merge → Pull request is a draft. Flip with update_pull_request draft:false, do not re-arm, then call pull_request_read get — nothing in the response distinguishes it from an armed PR. Call enable_pr_auto_merge and read the empty method: / enabled at fields.

Dedup

⚠️ Declared, NOT claimed. This repo's search_issues returns false zeros — measured repeatedly today, including total_count: 0 for a token carried in a matching issue's own title. ⇒ a zero from that instrument is not evidence of absence. Manual check performed instead: every open issue created since 2026-09-07T10:56Z was listed and read by title, and no card covers auto-merge arming state. Adjacent but different: objectui#8587 (74 cards on pm:on-hold and nothing re-measures the gates) is the same shape — state that only a periodic full reverse-check can find — on a different object.

Related

objectui#7014 (the reverse-check discipline, and its closing note) · objectui#8587 (stale pm:on-hold state, same shape) · objectui#8553 / #8664 (the pair where the unread arm state mattered)

Activity

  1. added
    domain:devxobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repo
    on Sep 8, 2026
  2. os-justin commented on Sep 8, 2026

    @os-justin
    CollaboratorAuthor

    Outcome, 30 minutes later — all six merged. The arm was the only thing missing.

    Re-listed the open PRs after arming. Every one of the six is gone from the open set:

    PR state before state after arming
    #8622 green since 17:54Z, open 3h16m merged
    #8658 open merged
    #8660 open merged
    #8661 open merged
    #8662 open merged
    #8664 open merged

    ⇒ none of them was blocked on anything. No red check, no conflict, no missing approval, no queue backlog. Six finished PRs sat in the open set solely because a single idempotent call had not been made, and they merged within half an hour of it being made.

    ⭐ That converts this from "a state you cannot read" into "a state you cannot read, and it was silently costing throughput the whole time." The three-hour figure on #8622 was not the worst case — it was just the one I happened to measure; the others had been open for varying periods with nothing wrong with them either.

    And the knock-on it was hiding

    #8664 is the ObjectTree flake repair. PR #8553 had been red for hours on ObjectTree.contractEnvelope-6839.test.tsx:124 — a failure that is not its own (its diff is packages/types and cannot reach plugin-tree). The standing read was "waiting for #8664 to land." #8664 was not going to land, because it was not armed.

    ⇒ an unread arm state does not just delay its own PR; it silently converts every PR waiting on that one into an indefinite wait that looks like patience. #8553's branch is updated now that #8664 is in main.

    What this adds to the directions above

    It sharpens the case for B (a scheduled sweep) over C (protocol only): the protocol here was not merely un-followed, it was believed to have been followed — I had recorded #8664 as armed. A remembered step cannot catch a step you remember doing. A (make auto_merge readable) remains the cheap prerequisite that would let any sweep, or any person, check it at all.


    Generated by Claude Code

  3. os-justin commented on Sep 9, 2026

    @os-justin
    CollaboratorAuthor

    ⚠️⚠️ CORRECTION — the instrument this card published is AMBIGUOUS, and I acted on it wrongly

    This card told readers that enable_pr_auto_merge returning an empty method: / enabled at means "the PR was NOT armed." ⛔ That is not what it means, and I have just proved it the expensive way.

    What happened

    main accepted no merge between 22:07:50Z and now — over two hours, while ten PRs sat green. I probed eight of them with the instrument above. All eight returned the empty echo. I concluded they had been silently disarmed, said so, and re-armed all eight.

    Then I looked at the actual queue:

    $ git ls-remote --heads origin 'gh-readonly-queue/*'
    gh-readonly-queue/main/pr-8683-da5e4f69e…     ← built on main's head
    gh-readonly-queue/main/pr-8684-4d11ec5cd…
    gh-readonly-queue/main/pr-8685-8428402aa…
    gh-readonly-queue/main/pr-8687-7c73ae99f…
    gh-readonly-queue/main/pr-8656-9c93b96e8…
    gh-readonly-queue/main/pr-8644-a9842c8f3…
    gh-readonly-queue/main/pr-8689-337a35f40…
    gh-readonly-queue/main/pr-8692-b8968b1c8…
    gh-readonly-queue/main/pr-8698-ba07f4fa7…
    gh-readonly-queue/main/pr-8697-65cf8f388…
    gh-readonly-queue/main/pr-8699-a04bf487a…
    gh-readonly-queue/main/pr-8646-d2d9779f6…
    

    Twelve entries, stacked — each branch's name carries the previous entry's head sha, so this is one speculative chain, and every merge_group CI run I checked came back success (pr-8644 23:38→23:54, pr-8689 23:39→23:56, pr-8692 23:39→23:56, pr-8698 23:40→23:56, pr-8697 23:55→00:11, pr-8699 23:56→00:11; pr-8707 still in progress).

    ⇒ Nothing was disarmed. Nothing was stalled. The whole batch is queued and building. main will jump forward by about a dozen commits when the chain completes, which is exactly what a two-hour flat git log looks like from the outside while a deep speculative chain runs.

    ⭐ The actual semantics, and why the instrument cannot tell

    GitHub clears the PR-level auto-merge flag when the PR is taken into the merge queue. So the empty echo has (at least) two causes:

    echo meaning
    method: MERGE, enabled at <ts> armed, and not yet taken into the queue
    method: , enabled at either never armed or currently in the merge queue

    ⇒ the reading this card published collapses "nobody armed it" with "it is queued and building right now" — opposite states, one needing action and one needing to be left alone.

    ⚠️ And the action it invites is not harmless: re-arming a PR that is already in the queue can dequeue and requeue it, discarding the speculative CI already spent on it and on every entry stacked above it. I did that to eight PRs. The queue branches survived, so this time it appears to have been absorbed — ⛔ but that is luck, not design, and the next reader may not be as lucky.

    What still stands from the original finding

    ⭐ The 21:10 measurement is unaffected and was real. Six PRs were genuinely unarmed — #8622 had been fully green for 3h16m — and all six merged within thirty minutes of being armed. At that moment the queue was empty, so the empty echo had only one possible cause. ⇒ the finding was sound; the instrument I generalised from it was not.

    ⭐ And the core defect is now WORSE, not better: pull_request_read does not expose auto-merge state, and the one workaround this card offered turns out to be ambiguous and side-effecting. There is currently no safe read of a PR's auto-merge state from this toolset.

    ⇒ direction A (make auto_merge readable) is no longer the cheap nice-to-have I framed it as — it is the only correct fix. B (a scheduled sweep) is actively dangerous while the only available probe is a write that can disturb the queue. ⚠️ Any sweep built on the current instrument would periodically kick healthy PRs out of the merge queue.

    The reliable read, for anyone doing this before A lands

    ⛔ Do not call enable_pr_auto_merge to find out whether a PR is armed. Instead:

    git ls-remote --heads origin 'gh-readonly-queue/*'
    

    A gh-readonly-queue/main/pr-<N>-<sha> branch means PR N is in the queue — armed, accepted, and building. It costs nothing, touches nothing, and answers the question the write only guesses at. Absence from that list plus a green head is the case that actually warrants arming.

    The lesson, and it is mine

    I spent tonight cataloguing instruments whose readings are not what they appear to be — a census keyed by a matcher, a sweep of wait expressions read as behaviour, a pin whose \s* swallowed the distinction it existed to make. ⇒ I then published one of my own, on this card, and acted on it without a control. The control was available and cheap: the queue branches were one git ls-remote away the whole time.

    A zero-hit reading is evidence only if a lit control fires. I wrote that into a dozen dispatches today and did not apply it to my own instrument.


    Generated by Claude Code

  4. os-justin commented on Sep 9, 2026

    @os-justin
    CollaboratorAuthor

    ⭐ The mechanism, confirmed — a queue EJECTION silently disarms the PR, and nothing says so

    The correction above established that an empty echo can mean "queued and building." With the queue now draining I have the other half, measured on a PR I can trace end to end.

    PR #8707:

    time observation
    23:54 armed by me (echo returned a timestamp)
    00:05 in the queue — gh-readonly-queue/main/pr-8707-7d6632ff…, its merge_group CI running
    00:45 not in the queue, not in main, still open, and the arm echo comes back empty

    ⇒ it entered the queue, left the queue, did not merge, and was disarmed. That is an ejection, and nothing anywhere reports it: the PR shows state: open, its own check runs are green, and mergeable_state reads unknown. The only evidence it was ever queued is a branch that no longer exists.

    Same reading for #8706 and #8711. #8702 by contrast still echoes its original 23:27:32Z — armed, never yet taken.

    ⇒ this is worse than the card's original finding. It is not "someone forgot to arm six PRs" — it is the queue disarming PRs as a side effect of ejecting them, so a PR that was correctly armed, correctly green, and correctly queued ends up parked with no arm and no signal. ⚠️ Left alone it never merges, and the only way anyone finds out is a periodic full reverse-check — the same discipline that found the original six.

    ⭐ The complete instrument, which needs BOTH reads

    Neither the queue-branch listing nor the arm echo is sufficient alone. Together they disambiguate:

    in queue? in main? open? arm echo state action
    yes – – empty queued and building ⛔ leave alone — arming here can dequeue it
    no no yes timestamp armed, waiting to be taken leave alone
    no no yes empty ejected, or never armed arm it
    no yes – – merged done
    git ls-remote --heads origin 'gh-readonly-queue/*'     ← free, no side effects, answers column 1
    git log --oneline <base>..origin/main                  ← free, answers column 2
    enable_pr_auto_merge                                   ← ⚠️ a WRITE; only ever call it once
                                                              columns 1 and 2 say it is safe
    

    ⚠️ The order matters and it is the whole safety property: read the queue first. My earlier mistake was calling the write before the free read, on eight PRs, several of which were mid-build.

    What this does to the directions

    • A (make auto_merge readable) — still the only correct fix, and now clearly insufficient on its own: a readable flag would have shown false on test(gantt): anchor both contractEnvelope-6839 refusal arms on completion, and diagnose the six siblings #8707 without saying why, and "never armed" versus "ejected two minutes ago" call for different responses.
    • ⇒ A needs a companion: surface the ejection. A PR removed from the queue without merging is an event; today it leaves no trace on the PR at all. ⚠️ That, not the unreadable flag, is what actually costs throughput.
    • B (a scheduled sweep) becomes safe only built on the two free reads above, never on the write.

    Tonight's tally, for whoever grades this

    Between 22:07 and 00:45 main accepted 5 merges while I had ~10 PRs armed and green. Of those ten: five landed, five did not — and of the five that did not, three were disarmed without merging and one was still waiting to be taken. ⇒ roughly a third of a night's finished work parked silently, recoverable only by a manual reverse-check that nothing schedules.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:devxobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repotooling

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions