Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions scripts/cast-vote.py
Original file line number Diff line number Diff line change
Expand Up @@ -638,15 +638,21 @@ def own_head_window(
f"`--cycles-log DIR` (or `--prev-cycle <id>`) - and ask again (issue #2275)"
), note

if pushed >= window.start:
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()} "
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} "
Expand Down
19 changes: 17 additions & 2 deletions scripts/check-vote-count.py
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,20 @@ def own_head_window(
f"the head's own window cannot be decided from push time {push_time!r}, "
"which is not an instant (issue #1408)"
)
if pushed < window.start and not window.boundary_unmeasured:
# The clause's span has **two** ends: inside is `start <= pushed < end`, and a push at
# or after the far end belongs to the cycle that followed this one, so it is not this
# cycle's work and the vote counts (issue #2334). Without the far end the span reached
# forward without limit: `cyc20261016-181619`, which pushed nothing, had its vote on a
# head `cyc20261016-183420` pushed voided, and the release PR #2326 waited a cycle at
# 2/3 for it.
#
# `below_unmeasured` keeps issue #2275's state apart from that answer: a push *below*
# the start is the one boundary that was never measured, and it is not the same finding
# as a push above the far end - the narrowed window credits a vote where the one that
# was never read would have voided it, while a push above the far end is outside a span
# whose other end *was* measured.
below_unmeasured = window.boundary_unmeasured and pushed < window.start
if not window.covers(pushed) and not below_unmeasured:
return False, ""
# The clause is about a head *this* instance pushed, and the window is only a proxy
# for that: a head the peer pushed inside a gap between this host's cycles fell inside
Expand Down Expand Up @@ -786,6 +799,7 @@ def own_head_window(
else ""
)
widened = f" {window.inferred_clause()}." if window.inferred else ""
far = f", until {window.window_end_text()}" if window.end is not None else ""
# What this reason may say is bounded by what it is a reason *about*: this counter
# reads reviews that were posted, so a sentence saying `cast-vote.py` refused to post
# "this vote" names an action no tool took, on the vote the row reports (measured
Expand All @@ -795,7 +809,8 @@ def own_head_window(
# *state* clauses above stay, because both tools share the state.
return True, (
f"cast inside the window this vote's cycle treats as its own - the head was "
f"pushed {push_time}, at or after {window.window_start_text()} ({window.source}"
f"pushed {push_time}, at or after {window.window_start_text()}{far} "
f"({window.source}"
f"{narrowed}); a cycle does not vote on a head it pushed, and the window "
"immediately before it counts as its own, so this vote counts for nobody "
f"(issue #1408).{widened}"
Expand Down
148 changes: 131 additions & 17 deletions scripts/review-queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -619,18 +619,7 @@ def previous_cycle(cycle: str, cycles_logs) -> tuple[str, str, str]:
logs = tuple(Path(entry) for entry in (cycles_logs or ()))
if not logs:
return "", "no cycle-record directory was named", "no cycle-record directory was named"
found: dict[str, Path] = {}
unreadable: list[str] = []
for log in logs:
try:
names = [entry.name for entry in log.iterdir()]
except OSError as exc:
unreadable.append(f"{log} ({exc.strerror or exc})")
continue
for name in names:
match = _CYCLE_RECORD.fullmatch(name)
if match:
found.setdefault("cyc" + match.group(1), log)
found, unreadable = _cycle_record_ids(cycles_logs)
named = ", ".join(str(log) for log in logs)
shortfall = "; ".join(unreadable)
if not found:
Expand All @@ -649,6 +638,66 @@ def previous_cycle(cycle: str, cycles_logs) -> tuple[str, str, str]:
return (earlier[-1], where, shortfall) if earlier else ("", where, shortfall)


def _cycle_record_ids(cycles_logs) -> tuple[dict[str, Path], list[str]]:
"""Every cycle id the record corpus holds, and the roots that were **not listed**.

The one scan both boundary resolvers read — `previous_cycle` takes the newest id below
this cycle's, `following_cycle` the earliest id above it — because a window is one span
and its two ends must see the same corpus. Resolved from two separate readings of the
roots, the same head reads one way in the queue and another at the poster, which is the
divergence this family has already paid for once (issue #2218).

A root that cannot be listed is *named* rather than passed over: an answer drawn from
the readable half is still an answer, but a reader has to know which half it came from.
"""
found: dict[str, Path] = {}
unreadable: list[str] = []
for log in tuple(Path(entry) for entry in (cycles_logs or ())):
try:
names = [entry.name for entry in log.iterdir()]
except OSError as exc:
unreadable.append(f"{log} ({exc.strerror or exc})")
continue
for name in names:
match = _CYCLE_RECORD.fullmatch(name)
if match:
found.setdefault("cyc" + match.group(1), log)
return found, unreadable


def following_cycle(cycle: str, cycles_logs) -> tuple[str, str, str]:
"""`(the cycle that follows `cycle`, where it was found, why the corpus fell short)`.

The **earliest** cycle record whose id sorts after this cycle's; ids are fixed width,
so string order is time order. This is the window's far bound, and the clause needs it
because a cycle's run ends where the next cycle begins: a head pushed at or after that
instant was pushed by that later cycle, so it is not this one's own work however the
clock reads (issue #2334).

`("", reason, ...)` when there is none — the *ordinary* state here rather than an
accident: the newest cycle is the one posting a vote and nothing follows it yet, so its
span stays open at the far end. The third element keeps `previous_cycle`'s meaning (the
roots that were not listed), and its direction is the safe one: a corpus nobody could
read has not said there is no later cycle either, so the bound stays open, which leaves
the window **wide** — a delay, never a vote credited.
"""
found, unreadable = _cycle_record_ids(cycles_logs)
named = ", ".join(str(log) for log in (cycles_logs or ()))
shortfall = "; ".join(unreadable)
later = sorted(entry for entry in found if entry > cycle)
if later:
where = str(found[later[0]])
else:
where = (
f"no cycle record after {cycle} in {named}"
if named
else "no cycle-record directory was named"
)
if unreadable:
where += f" ({shortfall} could not be read)"
return (later[0], where, shortfall) if later else ("", where, shortfall)


@dataclass
class Window:
"""The span of cycles whose heads this cycle must neither vote on nor merge.
Expand Down Expand Up @@ -692,11 +741,36 @@ class Window:
inferred: bool = False
dispatch_unread: str = ""
corpus_shortfall: str = ""
#: The far bound of the span: the instant the **next** cycle started, or `None` while
#: this cycle is the newest — its run has not ended, which is the state every cycle is
#: in when it posts a vote. A head pushed at or after this instant was pushed by that
#: later cycle, so it is not this cycle's own work however the clock reads (issue
#: #2334).
end: datetime | None = None

@property
def applied(self) -> bool:
return self.start is not None

def covers(self, pushed: datetime) -> bool:
"""Is this push inside the run of the cycle whose head-window this is?

The clause is about a head **this** cycle pushed, and a cycle's run ends where the
next cycle begins: the span is `[start, end)`, with `end` open while this cycle is
the newest — nothing after it exists to take the run over, so every push since its
start is its own. The reason a far bound is needed at all is that without one the
span reaches *forward* without limit, and a head pushed by a later cycle was
reported as this one's own work (issue #2334: `cyc20261016-181619` was voided on a
head `cyc20261016-183420` pushed).

One predicate, asked by all three carriers — the queue that prints the verdict,
the counter that voids a posted vote, the poster that refuses to spend one — so
the same head cannot read `vote` in one and be voided by another.
"""
if self.start is None:
return False
return pushed >= self.start and (self.end is None or pushed < self.end)

@property
def boundary_unmeasured(self) -> bool:
"""Was the boundary the clause asks about ever measured?
Expand All @@ -719,6 +793,16 @@ def window_start_text(self) -> str:
"""The window's start as one readable instant (empty when unapplied)."""
return self.start.isoformat(timespec="seconds") if self.start else ""

def window_end_text(self) -> str:
"""The window's far bound as one readable instant (empty when it has none).

Empty in two states a reader must not confuse: a cycle whose span is still open
because nothing followed it — the state every posting cycle is in — and a window
that could not be applied at all. Both leave the span open above, which is the
safe reading, so neither invents an instant.
"""
return self.end.isoformat(timespec="seconds") if self.end else ""

def inferred_clause(self) -> str:
"""The sentence a records-only resolution owes its reader (empty otherwise).

Expand Down Expand Up @@ -758,7 +842,8 @@ def inferred_clause(self) -> str:


def abstain_window(cycle: str, previous: str, where: str,
inferred: bool = False, corpus_shortfall: str = "") -> Window:
inferred: bool = False, corpus_shortfall: str = "",
following: str = "") -> Window:
"""The window, from the previous cycle's id — or a narrower one that says so.

The previous cycle's start is preferred because it is the wider window: a head
Expand Down Expand Up @@ -795,12 +880,24 @@ def abstain_window(cycle: str, previous: str, where: str,
**A resolved boundary stays a reading even beside a shortfall**, and a caller that
named its own boundary (`--prev-cycle`, `inferred=False`) is not second-guessed:
neither asked the records what only the records answer.

`following` is the cycle *after* this one, and it closes the span at the far end: a
head pushed from the next cycle's start onward was pushed by that cycle, not by this
one, so the clause has nothing to say about it (issue #2334). It is `""` for the newest
cycle — the state every posting cycle is in — and the span is then open above, which is
right: nothing has taken the run over yet.
"""
own = cycle_start(cycle)
start = cycle_start(previous)
# The far bound, and it is the cycle *after* this one rather than the one before: a
# cycle's run ends where the next cycle begins, so a push from that instant onward
# belongs to that later cycle (issue #2334). `""` — no later cycle — leaves the span
# open, which is the state this cycle is in while it is the one running.
end = cycle_start(following)
if start is not None:
return Window(
start=start,
end=end,
source=f"previous cycle {previous}" + (f" ({where})" if where else ""),
previous=previous,
inferred=inferred,
Expand All @@ -820,6 +917,7 @@ def abstain_window(cycle: str, previous: str, where: str,
# answered "votable" from an end nobody measured is the defect this closes.
return Window(
start=own,
end=end,
source=f"this cycle only (started {own.isoformat(timespec='seconds')})",
unresolved=(
f"{corpus_shortfall} could not be read, so the start of the cycle "
Expand All @@ -838,7 +936,7 @@ def abstain_window(cycle: str, previous: str, where: str,
)
return Window(start=None, source="", unresolved=where or f"{cycle!r} is not a cycle id",
previous=previous, inferred=inferred,
corpus_shortfall=corpus_shortfall)
corpus_shortfall=corpus_shortfall, end=end)


def with_dispatches(window: Window, cycle: str, dispatched: Sequence[str]) -> Window:
Expand Down Expand Up @@ -919,6 +1017,11 @@ def with_dispatches(window: Window, cycle: str, dispatched: Sequence[str]) -> Wi
unresolved="",
previous=earlier[-1],
corpus_shortfall=window.corpus_shortfall,
# Both exits here build a **fresh** window rather than a `replace`, so
# every field the constructor is not handed is dropped silently. The far
# bound is not a function of the boundary being moved — the next cycle's
# start is the same fact either way — so it is carried (issue #2334).
end=window.end,
)
if boundary == window.start:
return replace(window, inferred=False)
Expand All @@ -939,6 +1042,11 @@ def with_dispatches(window: Window, cycle: str, dispatched: Sequence[str]) -> Wi
unresolved="",
previous=window.previous,
corpus_shortfall=window.corpus_shortfall,
# The far bound moves with the window and not with its start: `boundary` is a
# dispatched cycle *before* this one, so it cannot be at or past `end`, which is
# the cycle *after* this one. Dropping it here would put the span back to having
# no far end, which is the defect `end` exists to close (issue #2334).
end=window.end,
)


Expand Down Expand Up @@ -981,14 +1089,20 @@ def own_window(
it also stays clear when `prev_cycle` named the boundary.
"""
roots = resolve_cycle_logs(cycles_log)
# The far bound is resolved for **every** caller, `--prev-cycle` included. Naming the
# lower boundary says which question the caller asked about the *start*; the span's
# other end is a fact of the corpus either way, and a caller that named its start did
# not thereby claim the run of every cycle that followed it (issue #2334).
following, _following_where, _following_shortfall = following_cycle(cycle, roots)
if prev_cycle:
# The caller's own answer, taken as named: a caller that named its boundary asked
# a different question from one that asked the records, and the dispatches are
# not consulted for it.
return abstain_window(cycle, prev_cycle, "named by --prev-cycle", inferred=False)
return abstain_window(cycle, prev_cycle, "named by --prev-cycle", inferred=False,
following=following)
previous, where, shortfall = previous_cycle(cycle, roots)
window = abstain_window(cycle, previous, where, inferred=True,
corpus_shortfall=shortfall)
corpus_shortfall=shortfall, following=following)
corpora = resolve_session_dirs(sessions_dir, roots)
ids, unreadable = dispatched_ids(corpora)
return replace(
Expand Down Expand Up @@ -1659,7 +1773,7 @@ def next_action(reading: Reading, cycle: str | None = None, repo: str = REPO,
if reading.head_pusher
else "the head carries no pusher reading, so the clause is kept"
)
if pushed is not None and pushed >= window.start and not other_pusher:
if pushed is not None and window.covers(pushed) and not other_pusher:
return Action(
kind="abstain",
why=f"head pushed {reading.head_pushed_at}, inside the window this cycle "
Expand Down
39 changes: 39 additions & 0 deletions tests/test_check_vote_count.py
Original file line number Diff line number Diff line change
Expand Up @@ -2138,6 +2138,45 @@ def test_a_vote_cast_inside_the_voting_cycles_own_window_does_not_count(
assert "inside the window" not in out, out


def test_a_vote_on_a_head_pushed_by_a_later_cycle_counts(mod, monkeypatch, capsys, tmp_path):
"""Issue #2334: the span's far bound, and the release PR it unblocked.

Measured 2026-10-16 on #2326: the head `54d9f18a` was pushed by `cyc20261016-183420`
(18:34:20), while the ✅ under it was cast by `cyc20261016-181619` — a cycle that began
at 18:16:19 and pushed nothing. With a start and no end the span ran forward to the
reading instant, the push fell inside it, and a vote by a cycle that had done no such
work was voided: the release PR waited a cycle at 2/3.

The window ends where the *next* cycle begins, so that push belongs to the cycle that
made it. The two arms differ in the corpus alone — the push instant is the same one —
so an instrument that voided everything, or nothing, fails one of them.
"""
pushed = _push(2026, 9, 11, 9, 0) # 09:00, pushed by the cycle that followed this one
vote_at = _push(2026, 9, 11, 10, 0)

records = tmp_path / "records"
records.mkdir()
(records / "cycle-20260911-083000.md").write_text("x", encoding="utf-8")
later = FakeGh([_approve("cyc20260911-080000", vote_at)], push_time=pushed)
rc = _run(mod, monkeypatch, later,
["1", "--min-votes", "1", "--cycles-log", str(records)])
out = capsys.readouterr().out
assert rc == 0, out
assert "READY 1/1" in out, out
assert "inside the window" not in out, out

# the control: the same vote, the same push, and a corpus that holds no later cycle —
# so the span has no far bound to stop it, which is the reading #2334 corrects
empty = tmp_path / "empty"
empty.mkdir()
no_later = FakeGh([_approve("cyc20260911-080000", vote_at)], push_time=pushed)
rc = _run(mod, monkeypatch, no_later,
["1", "--min-votes", "1", "--cycles-log", str(empty)])
out = capsys.readouterr().out
assert rc == 1, out
assert "VOID cyc20260911-080000 - cast inside the window" in out, out


def test_a_vote_on_a_head_another_instance_pushed_inside_the_window_counts(
mod, monkeypatch, capsys
):
Expand Down
Loading
Loading