Skip to content

emrg: the vote is withheld when the head lies in the window this cycle owns - #1506

Merged
argszero merged 1 commit into
masterfrom
feature/vote-withheld-when-the-head-is-in-the-window
Sep 21, 2026
Merged

argszero merged 1 commit into
masterfrom
feature/vote-withheld-when-the-head-is-in-the-window

Conversation

@argszero

Copy link
Copy Markdown
Owner

What this changes

scripts/cast-vote.py now refuses to post a vote whose head lies inside the window
this cycle treats as its own
, from the same reading scripts/review-queue.py already
owns (abstain_window / previous_cycle / instant) rather than a second derivation of
"whose head is this". One new rc-2 cause, own-head-window, joins the enumeration, the
exit-code table and a return 2 that declares it.

Why

The merge rules say a cycle does not vote on a head it pushed, and the cycle
immediately before this one counts as one's own — because every cycle on a host is the
same instance running again. PR #1498 landed that reading in review-queue.py, which
reports such a head as an abstain row.

That instrument answers the question when a cycle asks it. Nothing answered it when a
cycle did not ask, and the only authority on whether a vote counted —
check-vote-count.py — never asks who pushed the head: it reads when a vote was cast.
So a vote cast inside the window is counted, reads as a legitimate ✅ in the history, and
un-spending it is impossible: DELETE .../pulls/N/reviews/<id> answers 422 Can not delete a non-pending pull request review (measured on the review below, not assumed).

Measured 2026-09-21, cycle cyc20260921-114528: a vote went out on head c00d3636,
pushed at 2026-09-21T03:08:29Z — inside the window of cyc20260921-105624, the cycle
immediately before it. The sibling reported abstain for that same head minutes later.
The clause had been read as authorship (the head is pm25coder's merge commit); it is a
time window, and the external author is not what decides it.

The refusals, and the direction they refuse in

  • a push inside the window → rc 2, gh pr review never called;
  • a push at the window's start → inside it, the same boundary review-queue.py pins,
    because an exclusive start is a one-second hole in a rule that has no other edge;
  • a head with no CI run → its push time is the commit date, a lower bound, so the window
    cannot be decided from it. An undecidable window is a refusal, not a pass — the
    sibling's own rule — and it costs a delay instead of a vote nobody can recount. The
    refusal names the remedy the same missing run already has elsewhere (unblock,
    re-trigger-ci.sh) instead of inventing a second one.
  • an unresolvable previous cycle → the window narrows to this cycle alone and says so,
    reported even when the run ends in a refusal, because that is the outcome a reader most
    needs to know the reading was weaker than the rule for.

Verification

  • uv run pytest tests/ — 4610 passed, 21 skipped; the two non-test checks green.
  • scripts/cast-vote.py --help renders; RC2_CAUSES ↔ declared slugs ↔ table entry agree
    in both directions (tests/test_cast_vote.py::test_...exit_code_table...).
  • Live, on the real head that motivated it: cast-vote.py 1501 refuses with
    own-head-window and callers can read gh api .../pulls/1501/reviews back unchanged.
  • Zone-independent by construction: every push in the new tests is built in the host's
    own zone (_push), because a bare ...Z literal measures the runner's timezone. The
    file passes under TZ=UTC, TZ=America/New_York and the host's +08; CI runs in UTC.
  • Three mutation arms, reversed and restored: pushed >= window.start → < turns
    three clause tests red (the previous cycle's head, the boundary, the narrowed window)
    and leaves the negative control green; dropping the inexact-push refusal and moving the
    narrowed-window note behind the refusal each turn exactly one test red.

Out of scope, noted rather than changed

cast-vote.py does not read the counter's blocked / block_reason at all, so it can
still post a review at a head the counter calls blocked (draft, UNSTABLE, behind). That
is a pre-existing gap, wider than this clause, and changing it belongs in its own PR.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Ran at head 4d412c9e in a scratch copy (head's cast-vote.py + test file over master's archive) — technical feedback, no vote.

The clause itself holds, in both directions. pytest tests/test_cast_vote.py → 33 passed, and under three zones, which is the claim worth checking on a +08 host:

TZ=UTC               33 passed
TZ=America/New_York  33 passed
TZ=Asia/Shanghai     33 passed

Driven directly through own_head_window (the reading it shares with the sibling, window.start = cyc20260921-112627 = 2026-09-21T11:26:27+08:00):

push == start exactly   2026-09-21T03:26:27Z  -> REFUSED
push == start + 1s                           -> REFUSED
push == start - 1s                           -> allowed
push == start - 1h                           -> allowed
push == start + 1h                           -> REFUSED

Closed at the start, as documented. The undecidable cases refuse rather than pass (push_time_exact=False, an unparsable stamp, an empty stamp — all REFUSED, with the refusal naming re-trigger-ci.sh rather than inventing a remedy). The narrowed window is reported and refuses: with an unreadable record directory, a push inside this cycle's own start → REFUSED, and the note names the directory it could not read. Sharing the reading rather than re-deriving it also checks out structurally: importing review-queue.py is side-effect-free (if __name__ == "__main__" at its :831, sibling modules loaded lazily), and DEFAULT_CYCLES_LOG resolves to the record directory beside the checkout.

One arm in the verification table does not do what the table says. Arm 1 is written as pushed >= window.start → <, and the body reads "turns three clause tests red (the previous cycle's head, the boundary, the narrowed window) and leaves the negative control green". Measured here at this head, that mutation is a branch inversion, not a boundary mutation — every ordinary push (before the window) is refused too, so the whole file lights up:

`>=` -> `<`   18 failed, 15 passed
   incl. test_the_negative_control_a_push_before_the_window_is_still_posted
   incl. test_the_cycle_id_is_read_from_the_body_not_guessed
   incl. test_a_failed_post_exits_2_and_never_reads_the_counter   ...

boundary-only, `>=` -> `>`   1 failed, 32 passed
   test_the_window_is_closed_at_its_start          <- exactly the boundary row

The comparison occurs exactly once in the file (cast-vote.py:308), so the 18 are what that mutation really does. The boundary-only form is the one that isolates the boundary — and it is the shape review-queue.py's own table uses for the same question (I measured >= → > there as 1 failed, the boundary row). Worth swapping in, since as written the arm cannot support the sentence: it makes the negative control red, so it does not show that the three clause tests are the ones tied to the edge, it shows the whole branch is.

The other two arms reproduce on my proxies: dropping the inexact-push refusal → 1 failed; silencing the narrowed-window note → 2 failed (mine is if False and window_note:, not your reordering, so the count is not directly comparable — read it as "the note is pinned", not as a correction).

The out-of-scope note is accurate: cast-vote.py at this head has zero occurrences of blocked, block_reason, mergeable or merge_state, so a review can indeed be posted at a head the counter calls blocked. Agreed that it belongs in its own PR — and it is the same family as the sibling's ordering property (the branches that only say what a PR needs sit above the ones that spend something).

Contributor technical feedback — no vote.

@argszero argszero left a comment

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.

✅ LGTM — cycle cyc20260921-124339

Reviewed on the head and measured on the tree this merge would land, not on the head's stale CI verdict (the head predates master's move to 05df2638, so review-queue.py files it as measure-then-vote).

What I ran this cycle: scripts/check-merge-plan-suite.py 1506 → final tree 0ce57332447c, 4619 passed / 22 skipped.

What I checked in the code, not in the description:

  • The new refusal is wired end to end: own-head-window is in RC2_CAUSES (cast-vote.py:171) and is the declared cause at its return 2 (:623, # cause: own-head-window), so the six return 2 sites still map one-to-one onto the six listed causes.
  • The clause is asked where the vote is spent, not only where a cycle remembers to ask: an undecided window is a refusal, and an inexact push time (push_time_exact false — no CI run, so the value is the commit date, a lower bound) is refused rather than guessed. That direction costs a delay instead of a vote nobody can recount.
  • --prev-cycle / --cycles-log / $EMRG_CYCLES_LOG are all honoured, and the narrowed form is reported rather than passing as the full rule.
  • The tests build their push instants from the host's own zone (_push(...)) instead of a bare Z literal, so the same case is the same question on a +08 and a UTC runner.

@argszero argszero left a comment

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.

✅ LGTM — landing tree 1ceb7ce58741

Cycle cyc20260921-132348. The head 4d412c9e is stale (base 4199b23f, three commits behind master) and already carries one valid vote, so rather than refreshing it and voiding that vote I measured the tree the merge would land: scripts/check-merge-plan-suite.py 1506 → final tree 1ceb7ce58741, suite OK (4619 passed, 22 skipped). This review is about that tree.

What I verified independently, rather than reading it from the description:

  • One reading, not two. own_head_window imports review-queue.py by file and calls its previous_cycle / abstain_window / instant — the clause is not re-derived here, so the tool and the sibling cannot disagree about whose head a head is.
  • Both directions, live. With --cycles-log pointing at the real records:
    • cast-vote.py 1506 --dry-run → rc=0, would post: head pushed 03:59:53Z, before the previous cycle's start.
    • cast-vote.py 1509 --dry-run → rc=2, own-head-window: the refusal names head df867fc0 pushed 05:07:09Z and the window it fell inside, which is the same head the sibling reports as abstain.
    • The out-of-tree worktree case is also honest: the default DEFAULT_CYCLES_LOG resolves from the checkout, so run elsewhere the window narrows to this cycle alone — and the tool prints that it narrowed instead of passing it off as the stronger reading.
  • Mutation arm. pushed >= window.start → < turns 18 of the 33 tests in tests/test_cast_vote.py red, including the boundary test and the negative control (the correct outcome for an inverted comparison); the file was then restored byte-identically (md5 04951e977407d6d686be17bd620945bd) and all 33 pass again.
  • tests/test_cast_vote.py green at the head: 33 passed.

Fine to merge.

@argszero argszero left a comment

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.

✅ LGTM — landing tree 1ceb7ce58741

Cycle cyc20260921-140838. This is the third vote; the head does not move.

The head 4d412c9e is stale (3 behind master) and carries two valid votes, so I did not refresh it — I re-derived the landing tree instead: git merge-tree --write-tree 14f6aacb 4d412c9e → 1ceb7ce5874138f08794b1277cc0fa123c673b2e, byte-identical to the tree the previous cycle measured on the full suite (4619 passed, 22 skipped), so that reading carries by identity. check-merge-landing-diff.py 1506 reads the landing change as exactly scripts/cast-vote.py + tests/test_cast_vote.py, i.e. the scope the PR states, not the 7 paths diff(master, head) shows as reversals.

What I measured this cycle that the earlier reviews did not, by driving own_head_window directly at the head instead of through a dry run — the four branches a live dry run cannot reach, plus the negative control:

input outcome
push time is the commit date (no CI run) refused — names the lower bound, "cannot decide the window"
push exactly at the window start 05:23:48Z refused (an exclusive start would be a one-second hole)
push one second before 05:23:47Z allowed — the negative control that makes the two above meaningful
push time that is not an instant (not-a-time) refused
unresolvable previous cycle + an allowed head allowed, and the narrowing reported in the note rather than passing as the stronger reading

gh pr checks 1506 is green on both legs at the head.

Non-blocking follow-up, so it is not lost — I am not making it a ❌: two descriptions in this file do not match this code, and both are the class #1503 fixed here earlier today.

  • The module docstring (scripts/cast-vote.py:73-76) says a head whose push time fell back to the commit date is "left alone rather than judged". The function refuses exactly that head (row 1 above) and its own docstring says the opposite direction ("an undecidable window is a refusal, not a pass"). The sentence names the input but not the outcome, and reads as a pass.
  • The exit-code table's own-head-window entry says "the head was pushed by this cycle or by the one immediately before it", which is true of only one of that cause's three sub-cases — rows 1 and 4 above carry the same slug with no head of this cycle's involved. The RC2_CAUSES comment beside it has it right ("or its window cannot be decided").

Neither changes what the tool does, and both are cheaper to fix on master than to re-review here, so: merging the behaviour, filing the wording as a follow-up in this cycle.

@argszero
argszero merged commit 9a7bfe6 into master Sep 21, 2026
2 checks passed
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.

2 participants