From 76be62630041543cdf58ad659e0b025144cc6177 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 16 Oct 2026 22:58:31 +0800 Subject: [PATCH 1/3] emrg: the abstain window gets its far bound, so a vote on a head pushed by a later cycle counts --- scripts/cast-vote.py | 10 ++- scripts/check-vote-count.py | 19 ++++- scripts/review-queue.py | 138 +++++++++++++++++++++++++++++---- tests/test_check_vote_count.py | 39 ++++++++++ tests/test_review_queue.py | 89 +++++++++++++++++++++ 5 files changed, 274 insertions(+), 21 deletions(-) diff --git a/scripts/cast-vote.py b/scripts/cast-vote.py index 1d720d51..4fc149b7 100644 --- a/scripts/cast-vote.py +++ b/scripts/cast-vote.py @@ -638,15 +638,21 @@ def own_head_window( f"`--cycles-log DIR` (or `--prev-cycle `) - 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} " diff --git a/scripts/check-vote-count.py b/scripts/check-vote-count.py index 120061f7..c7c2bf9b 100644 --- a/scripts/check-vote-count.py +++ b/scripts/check-vote-count.py @@ -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 @@ -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 @@ -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}" diff --git a/scripts/review-queue.py b/scripts/review-queue.py index e4aa2770..fd6f4d6a 100644 --- a/scripts/review-queue.py +++ b/scripts/review-queue.py @@ -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: @@ -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. @@ -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? @@ -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). @@ -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 @@ -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, @@ -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 " @@ -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: @@ -981,14 +1079,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( @@ -1659,7 +1763,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 " diff --git a/tests/test_check_vote_count.py b/tests/test_check_vote_count.py index 59bbe513..4349fff2 100644 --- a/tests/test_check_vote_count.py +++ b/tests/test_check_vote_count.py @@ -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 ): diff --git a/tests/test_review_queue.py b/tests/test_review_queue.py index 6ce9cca9..e057d5b0 100644 --- a/tests/test_review_queue.py +++ b/tests/test_review_queue.py @@ -1159,6 +1159,95 @@ def test_a_head_pushed_by_the_previous_cycle_is_an_abstain(mod, monkeypatch, cap assert PREV_CYCLE in out +def test_the_window_ends_where_the_following_cycle_begins(mod, tmp_path): + """Issue #2334: the span has a far bound, and it is the *next* cycle's start. + + Without one the span reaches forward without limit, so a head pushed by a later cycle + was reported as this cycle's own work: on 2026-10-16 `cyc20261016-181619`, which pushed + nothing, was voided on a head `cyc20261016-183420` pushed, and release PR #2326 waited + a cycle at 2/3 for the vote. + """ + records = tmp_path / "records" + records.mkdir() + (records / f"cycle-{PREV_CYCLE[3:]}.md").write_text("x", encoding="utf-8") + (records / "cycle-20260917-223000.md").write_text("x", encoding="utf-8") + + window = mod.own_window(CYCLE, None, str(records), None) + + assert window.start == mod.cycle_start(PREV_CYCLE), "the start is unchanged by #2334" + assert window.end == mod.cycle_start("cyc20260917-223000"), ( + "the far bound is the instant the cycle after this one began" + ) + assert window.window_end_text() == mod.cycle_start( + "cyc20260917-223000" + ).isoformat(timespec="seconds") + assert window.covers(mod.instant("2026-09-17T22:20:00+08:00")) is True + assert window.covers(mod.instant("2026-09-17T22:35:00+08:00")) is False, ( + "a head pushed by the cycle that followed this one is that cycle's work, so this " + "cycle's clause has nothing to say about it" + ) + + +def test_a_window_with_no_later_cycle_is_open_at_its_far_end(mod, tmp_path): + """The state every posting cycle is in: nothing follows it yet, so the span stays open. + + This is the arm that keeps #2334 from becoming a window with no cycle to close it — an + open far end is not an unmeasured one, and the `None` must not be read as "closed at + once", which would refuse every vote a cycle ever casts. + """ + records = tmp_path / "records" + records.mkdir() + (records / f"cycle-{PREV_CYCLE[3:]}.md").write_text("x", encoding="utf-8") + + window = mod.own_window(CYCLE, None, str(records), None) + + assert window.end is None + assert window.window_end_text() == "" + assert window.covers(mod.instant("2026-09-17T23:59:00+08:00")) is True + + +def test_a_head_pushed_by_a_later_cycle_is_not_an_abstain_row(mod, monkeypatch, capsys, + tmp_path): + """The row half: the queue asks the same far bound the counter voices a vote on. + + The control is `test_a_head_pushed_past_a_corpus_with_no_later_cycle_reads_abstain`: + the same push instant, only the corpus apart — an arm that fails in either direction is + what a clause with a start and no end cannot pass. + """ + records = tmp_path / "records" + records.mkdir() + (records / f"cycle-{PREV_CYCLE[3:]}.md").write_text("x", encoding="utf-8") + (records / "cycle-20260917-223000.md").write_text("x", encoding="utf-8") + + pushed = _push(2026, 9, 17, 22, 35) # after 22:30:00, the next cycle's start + rc = _run(mod, monkeypatch, FakeVotes(reviews=[], push=pushed), FakeFresh(), + ["1", "--cycle", CYCLE, "--cycles-log", str(records)]) + out = capsys.readouterr().out + assert rc == 0 + assert f"cast-vote.py 1 --cycle {CYCLE}" in out, out + assert "inside the window this cycle treats as its own" not in out, out + + +def test_a_head_pushed_past_a_corpus_with_no_later_cycle_reads_abstain(mod, monkeypatch, + capsys, tmp_path): + """The control for the row test above, and the over-blocking #2334 is about. + + Same push instant, same cycle, and a corpus that holds no cycle after this one: the + span then has no far bound and the head is inside it. Kept as its own test because the + queue's run installs the counter it reads as a sibling — a second `_run` in one test + reads the first run's fake — so a control arm here has to be a test of its own. + """ + only_prev = tmp_path / "only-prev" + only_prev.mkdir() + (only_prev / f"cycle-{PREV_CYCLE[3:]}.md").write_text("x", encoding="utf-8") + + pushed = _push(2026, 9, 17, 22, 35) + _run(mod, monkeypatch, FakeVotes(reviews=[], push=pushed), FakeFresh(), + ["1", "--cycle", CYCLE, "--cycles-log", str(only_prev)]) + out = capsys.readouterr().out + assert "inside the window this cycle treats as its own" in out, out + + def test_a_head_another_instance_pushed_is_not_this_cycles_own(mod, monkeypatch, capsys): """The clause is about a head *this* instance pushed; the window only proxies that. From 8afbb50030afe1556835b18346188f185d275c1b Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sat, 17 Oct 2026 00:11:44 +0800 Subject: [PATCH 2/3] emrg: pin the far-bound guards to the host's zone, so the UTC CI legs agree --- tests/test_review_queue.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_review_queue.py b/tests/test_review_queue.py index e057d5b0..eda2743c 100644 --- a/tests/test_review_queue.py +++ b/tests/test_review_queue.py @@ -1181,8 +1181,11 @@ def test_the_window_ends_where_the_following_cycle_begins(mod, tmp_path): assert window.window_end_text() == mod.cycle_start( "cyc20260917-223000" ).isoformat(timespec="seconds") - assert window.covers(mod.instant("2026-09-17T22:20:00+08:00")) is True - assert window.covers(mod.instant("2026-09-17T22:35:00+08:00")) is False, ( + # Both probes are built in the host's own zone, the way `_push` builds one, because + # `cycle_start` reads a cycle id as *local* wall clock: an `instant("…+08:00")` here + # asserts a property only a +08 host has — green there, red on the UTC CI legs (#2335). + assert window.covers(datetime(2026, 9, 17, 22, 20, tzinfo=LOCAL)) is True + assert window.covers(datetime(2026, 9, 17, 22, 35, tzinfo=LOCAL)) is False, ( "a head pushed by the cycle that followed this one is that cycle's work, so this " "cycle's clause has nothing to say about it" ) @@ -1203,7 +1206,8 @@ def test_a_window_with_no_later_cycle_is_open_at_its_far_end(mod, tmp_path): assert window.end is None assert window.window_end_text() == "" - assert window.covers(mod.instant("2026-09-17T23:59:00+08:00")) is True + # Local wall clock again, for the reason spelled out in the far-bound guard above. + assert window.covers(datetime(2026, 9, 17, 23, 59, tzinfo=LOCAL)) is True def test_a_head_pushed_by_a_later_cycle_is_not_an_abstain_row(mod, monkeypatch, capsys, From d6ad32e2e573579b6c95594dfa2a147515000ed0 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sat, 17 Oct 2026 01:41:19 +0800 Subject: [PATCH 3/3] emrg: a window the dispatch corpus moves keeps its far bound --- scripts/review-queue.py | 10 +++++++++ tests/test_review_queue.py | 44 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/scripts/review-queue.py b/scripts/review-queue.py index fd6f4d6a..600ab720 100644 --- a/scripts/review-queue.py +++ b/scripts/review-queue.py @@ -1017,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) @@ -1037,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, ) diff --git a/tests/test_review_queue.py b/tests/test_review_queue.py index eda2743c..06a4c868 100644 --- a/tests/test_review_queue.py +++ b/tests/test_review_queue.py @@ -1893,6 +1893,50 @@ def test_a_dispatch_that_fills_in_the_boundary_measures_the_far_end(mod): assert "gone" in moved.source, f"the source must name it: {moved.source!r}" +def test_a_window_the_dispatch_corpus_moves_keeps_its_far_bound(mod): + """Both exits of `with_dispatches` build a **fresh** `Window`, so any field the + constructor is not handed is dropped silently — and the far bound is not a function of + the boundary being moved: the cycle *after* this one began where it began either way. + + Measured 2026-10-17 on this host: the ordinary exit lost it, so a head the next cycle + pushed read `abstain` again — issue #2334's defect, back on the path the corpus takes + most often (it moves the boundary whenever a dispatched cycle is newer than the newest + record, which is the case the corpus exists for). The guard beside this one could not + catch it: it resolves a window with no `following`, so there was no far bound to lose. + """ + following = "cyc20260917-223000" # the next cycle: the span's far bound + window = mod.abstain_window(CYCLE, PREV_CYCLE, "/some/records", inferred=True, + following=following) + assert window.end == mod.cycle_start(following) + + # Exit 1 — a dispatched cycle between the record and this cycle's own id moves the + # start up to it, and the span must still end where the next cycle began. + moved = mod.with_dispatches(window, CYCLE, [PREV_CYCLE, GAP_CYCLE]) + assert moved.window_start_text() == mod.cycle_start(GAP_CYCLE).isoformat( + timespec="seconds") + assert moved.end == window.end, ( + "the far bound is the start of the cycle *after* this one, which moving the near " + "end does not touch" + ) + assert moved.covers(datetime(2026, 9, 17, 22, 35, tzinfo=LOCAL)) is False, ( + "22:35 local is past the next cycle's start, so that head is that cycle's work " + "however far up the near end was moved" + ) + + # Exit 2 — the same move where the record corpus could not be read at all, which is + # the exit that repairs an unmeasured start. + unmeasured = mod.abstain_window( + CYCLE, "", "gone (No such file or directory) could not be read", + inferred=True, corpus_shortfall="gone (No such file or directory)", + following=following, + ) + assert unmeasured.boundary_unmeasured is True + repaired = mod.with_dispatches(unmeasured, CYCLE, [PREV_CYCLE]) + assert repaired.end == mod.cycle_start(following), ( + "supplying the start the records could not measure says nothing about the far end" + ) + + def test_a_dispatch_older_than_the_record_does_not_move_the_window(mod): """The refinement runs one way. Where a record exists it is the answer, so an id from before it — a stale quotation, a re-delivered prompt of an older cycle — must