From 8591be98e1e224b25136e09ae3ec679499ceb624 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sat, 17 Oct 2026 07:03:15 +0800 Subject: [PATCH 1/2] emrg: only a head the voting cycle itself pushed is abstained on (#2338) --- emrg/server/evolution_prompt.md | 2 +- scripts/cast-vote.py | 172 +-- scripts/check-vote-count.py | 67 +- scripts/review-queue.py | 938 ++------------- tests/test_cast_vote.py | 362 +----- tests/test_check_vote_count.py | 207 +--- ...rinted_commands_carry_the_python_runner.py | 18 +- tests/test_review_queue.py | 1036 +++-------------- 8 files changed, 454 insertions(+), 2348 deletions(-) diff --git a/emrg/server/evolution_prompt.md b/emrg/server/evolution_prompt.md index af6e4ae5..22bcaabc 100644 --- a/emrg/server/evolution_prompt.md +++ b/emrg/server/evolution_prompt.md @@ -298,7 +298,7 @@ Vote history misleads in both directions, so ask `scripts/check-vote-count.py` ( **An old head carrying votes is not refreshed.** A push moves the head and **voids every vote standing on it**, so the tempting "just fix it up" destroys the thing it means to preserve: measure the tree it would land with `scripts/check-merge-freshness.py` and `scripts/check-merge-plan-suite.py` instead of refreshing, and vote on that reading — the head does not move, so the standing votes stay valid. The refreshing shape is `git fetch origin master && git merge FETCH_HEAD` and then push; rebase cannot ship here (the push is rejected, and the force-push it needs is forbidden). -**Abstention**: a cycle neither votes on nor merges a head it pushed itself, nor one pushed by the cycle immediately before it. A *cycle* here and everywhere in this file is **one run of this loop**, not the instance: a PR an earlier run pushed is one this run may review and vote on, and a PR this run pushed is not. +**Abstention**: a cycle neither votes on nor merges a head it pushed itself — the cycle's own run, and only that. A *cycle* here and everywhere in this file is **one run of this loop**, not the instance: a PR an earlier run pushed is one this run may review and vote on, and a PR this run pushed is not. The clause reads the head's push instant against this cycle's own run (from its own start to the start of the cycle that follows it), so a push before that run or after it belongs to whoever made it: the requirement "nor one pushed by the cycle immediately before it" is not part of the rule. **A parallel-cycle merge race is not a failure**: two cycles can both see the 3rd vote and call `gh pr merge`; the loser gets "not mergeable" or "already merged". Re-read `gh pr view --json state,mergedAt` — a set `mergedAt` means it landed, possibly by the other cycle; fetch master and confirm the commit is on `FETCH_HEAD`. Only a genuine rejection (conflict, red CI, ❌) blocks. diff --git a/scripts/cast-vote.py b/scripts/cast-vote.py index 4fc149b7..e9ba33dc 100644 --- a/scripts/cast-vote.py +++ b/scripts/cast-vote.py @@ -128,22 +128,18 @@ window, and the sibling reported `abstain` for it minutes later. So the clause is applied here too, from the same reading, and refuses to post. The -window is the previous cycle's start when it can be found (`--prev-cycle`, else the -newest cycle record that sorts before this cycle's id in `--cycles-log`, default +window is this cycle's own run: it starts at this cycle's own id — a cycle id *is* its +start instant, so the near bound needs no corpus — and ends where the cycle that follows +begins, read from the newest cycle record in `--cycles-log` (default `$EMRG_CYCLES_LOG`, else the directory the evolution template names: the project memory -root inside this checkout) and this -cycle's own start -when it cannot — narrowed and said so, never abandoned and never widened by a guess. -A head whose push time fell back to the commit date is **refused rather than judged**: -a lower bound cannot decide a window, so nothing is posted and the refusal names the -remedy that same missing run already has — get a run for the head, then ask again. The -counter calls such a head blocking for that same missing run, so the cost is a delay -rather than a vote nobody can recount. The other uncertain input — no previous cycle to -widen the window from — is *narrowed* rather than refused: the head is judged over this -cycle's own start and the narrowing is reported, because a clause applied over a smaller -window is a weaker reading than the rule states and must not pass as the stronger one. -Only a cycle id that names no instant at all leaves the clause inapplicable, and that is -a refusal. +root inside this checkout). A push below the start belongs to whoever made it before +this cycle ran, so the vote counts; the host ruled the older, wider reading away on +2026-10-16T22:18:15. A head whose push time fell back to the commit date is **refused +rather than judged**: a lower bound cannot decide a window, so nothing is posted and +the refusal names the remedy that same missing run already has — get a run for the head, +then ask again. The counter calls such a head blocking for that same missing run, so the +cost is a delay rather than a vote nobody can recount. Only a cycle id that names no +instant at all leaves the clause inapplicable, and that is a refusal. Why `-` exists (issue #1462) ---------------------------- @@ -327,12 +323,11 @@ def votes_counter(): #: The sibling that owns the abstention window (issue #1408; the reading itself landed -#: with PR #1498). The clause — *a cycle does not vote on a head it pushed*, with the -#: window immediately before this one counted as one's own — is defined there: its two -#: ends, its narrowed form when the previous cycle cannot be found, and why an inexact -#: push time never decides it. This tool asks for that reading rather than deriving a -#: second one, because a second reading of "whose head is this" is a second answer to -#: one question, and the two would disagree exactly when it matters. +#: with PR #1498). The clause — *a cycle does not vote on a head it pushed* — is +#: defined there: its two ends, and why an inexact push time never decides it. This +#: tool asks for that reading rather than deriving a second one, because a second +#: reading of "whose head is this" is a second answer to one question, and the two +#: would disagree exactly when it matters. _QUEUE = Path(__file__).resolve().parent / "review-queue.py" _queue: object | None = None @@ -521,15 +516,14 @@ def tree_claim_refusal(body: str, pr: int, repo: str) -> tuple[str | None, str]: def own_head_window( cycle: str, verdict: object, - prev_cycle: str | None = None, cycles_log: str | None = None, ) -> tuple[str, str]: """The abstention clause, applied at the moment the vote would be spent. Answers `(refusal, note)`: a non-empty `refusal` means **nothing was posted**, - and `note` is what to say when it is empty. `note` is also where a *narrowed* - window is reported — a clause applied over this cycle alone is a strictly weaker - reading than the one the rule states, so it says so rather than passing as it. + and `note` is what to say when it is empty — it carries the one exemption that + a *reading* decided rather than the clock (a head provably pushed by another + instance), so a reader can see which datum decided. Why this is here and not only in `review-queue.py`: that tool answers "may this cycle vote here" **when a cycle asks it**, and a cycle that does not ask spends @@ -540,6 +534,14 @@ def own_head_window( reported `abstain` for that same head minutes later, from the same window this refuses on. + **The window is this cycle's own run** — from its own start to the start of the + cycle that follows it — and only a push inside it is refused. The host ruled the + wider reading away on 2026-10-16T22:18:15 ("I think the requirement 'nor one pushed + by the cycle immediately before it' should not exist. That the cycle that pushed it + cannot vote is already enough" — translated from the Chinese original): a head the + *previous* cycle pushed is no longer this cycle's own work, and a push below this + cycle's own start belongs to whoever made it. + **An undecidable window is a refusal, not a pass** — that is the sibling's own rule (`an unresolved window is never reported as a pass`) and it is the only direction that costs a delay instead of a vote nobody can recount. A head with no @@ -568,33 +570,16 @@ def own_head_window( "instance's work and the abstention clause is not asked of it" ) # One producer for the window, shared with the queue that prints the verdict and the - # counter that voids a posted vote (`review-queue.own_window`). Resolved here from - # the records alone it was too early for 20 of the corpus's 350 cycle records - # (measured 2026-10-14), so a head pushed in such a gap was refused a vote it was - # entitled to post - a vote lost to an arithmetic difference between carriers rather - # than to the clause (issue #2218). `prev_cycle` is the caller's own answer and is - # not second-guessed. - window = queue.own_window(cycle, prev_cycle, cycles_log) - - note = "" - if window.unresolved: - narrowed = ( - f"only pushes at or after {window.window_start_text()} were checked" - if window.applied - else "and this cycle's own id names no instant" - ) - note = ( - "the abstention window could not be widened to the cycle before this one " - f"({window.unresolved}); it was applied as {window.source or 'nothing'} - " - f"{narrowed}" - ) + # counter that voids a posted vote (`review-queue.own_window`), so the same head + # cannot read `vote` in one and be refused by another (issue #2218). + window = queue.own_window(cycle, cycles_log) if not window.applied: return ( - f"the abstention clause cannot be applied: {note}. Nothing was posted - a " - "vote that may be this cycle's own work is not worth spending, and the " - "clause is the reason the question is asked at all (issue #1408)" - ), note + f"the abstention clause cannot be applied: {window.unresolved}. Nothing was " + "posted - a vote that may be this cycle's own work is not worth spending, and " + "the clause is the reason the question is asked at all (issue #1408)" + ), "" head = str(getattr(verdict, "head_sha", "")) push_time = str(getattr(verdict, "push_time", "")) @@ -609,62 +594,35 @@ def own_head_window( "(`gh workflow run test.yml --ref `, or " "`bash scripts/re-trigger-ci.sh `), then ask again - " f"`{RUNNER} scripts/review-queue.py --cycle ` reads the same head the same way" - ), note + ), "" pushed = queue.instant(push_time) if pushed is None: return ( f"the window cannot be decided from head {head[:8]}: its push time " f"({push_time!r}) is not an instant. Nothing was posted" - ), note - - if window.boundary_unmeasured and pushed < window.start: - # Issue #2275's posting half: the narrowed window is real, its far end is not, and - # this push falls below the start — so whether it is inside the window this cycle - # treats as its own was never decided. Posting under it spends a vote the counter - # voids, which is the one thing this tool exists to prevent; the counter takes the - # same branch (both ask `Window.boundary_unmeasured`), so the two readings cannot - # disagree about the same head. - return ( - f"the head {head[:8]} was pushed {push_time}, below the window this cycle " - f"treats as its own, which starts {window.window_start_text()} " - f"({window.source}) - and the boundary the wider window uses, the start of the " - "cycle before this one, which is earlier than that start, was never " - f"measured, because the cycle-record corpus could not be read " - f"({window.corpus_shortfall}). Whether the push falls inside the window this " - "clause asks about cannot be decided from here, and a boundary nobody measured " - "is not passed: the narrowed window would credit a vote cast inside the one " - "that was never read. Nothing was posted. Pass the corpus the window needs - " - f"`--cycles-log DIR` (or `--prev-cycle `) - and ask again (issue #2275)" - ), note - - if window.covers(pushed): - # The same disclosure the report carries, from the same producer: a window read - # off the records can be wider than the clause, and here that costs a *posted - # vote* rather than a reading - this refusal is the one place a cycle cannot - # simply re-read next cycle (issue #2214). - # - # Both ends of the span, not its start alone (issue #2334). A posting cycle is the - # newest one, so its far bound is open and this is the comparison it always was; - # the far end is asked all the same, because the predicate the three carriers share - # is the one place that difference is allowed to live. - widened = f" {window.inferred_clause()}." if window.inferred else "" - far = f", until {window.window_end_text()}" if window.end is not None else "" - return ( - f"the head {head[:8]} was pushed {push_time}, inside the window this cycle " - f"treats as its own, which starts {window.window_start_text()}{far} " - f"({window.source}) - a cycle does not vote on a head it pushed, and the " - "window immediately before this one counts as its own as well, because every " - f"cycle on a host is the same instance running again.{widened} " - "Nothing was posted. The next vote here has to come from a later cycle " - "(the same head is an `abstain` row in " - f"`{RUNNER} scripts/review-queue.py --cycle `); if the head is stale and its " - "votes are at risk, measure the tree the merge would land instead of " - "refreshing it - a push voids the votes it was meant to preserve " - f"(`{RUNNER} scripts/check-merge-plan-suite.py `)" - ), note - - return "", note + ), "" + + # The clause's span has **two** ends, and both of them can put a push outside it: a + # push below `start` belongs to whoever made it before this cycle began, and one at + # or after `end` belongs to the cycle that followed (issue #2334). Only a push inside + # the run is this cycle's own work, so this is the comparison that decides — and it is + # the same predicate the queue prints and the counter voids on. + if not window.covers(pushed): + return "", "" + + far = f", until {window.window_end_text()}" if window.end is not None else "" + return ( + f"the head {head[:8]} was pushed {push_time}, inside the run this cycle treats " + f"as its own, which starts {window.window_start_text()}{far} " + f"({window.source}) - a cycle does not vote on a head it pushed. " + "Nothing was posted. The next vote here has to come from a later cycle " + "(the same head is an `abstain` row in " + f"`{RUNNER} scripts/review-queue.py --cycle `); if the head is stale and its " + "votes are at risk, measure the tree the merge would land instead of " + "refreshing it - a push voids the votes it was meant to preserve " + f"(`{RUNNER} scripts/check-merge-plan-suite.py `)" + ), "" def _gh(args: list[str], stdin: str | None = None) -> subprocess.CompletedProcess: @@ -926,18 +884,12 @@ def main(argv: list[str] | None = None) -> int: default=None, help="the cycle id the vote is cast under; must be the one in the body", ) - parser.add_argument( - "--prev-cycle", - default=None, - help="the cycle immediately before this one, whose start is the abstention " - "window's start; read from the cycle records when omitted", - ) parser.add_argument( "--cycles-log", default=None, help="directory (or several, joined by `os.pathsep`) holding the " - "`cycle--