Domain: recurring-schedule expansion (calendar rules)

Issue:
The final scheduled run of a window never fires when the window's end lands exactly on a run time. For example, a daily 09:00 job expanded over a window ending at 09:00 on the last day silently omits that day's run, and asking for the next run when the only remaining one falls exactly at the horizon returns nothing. Runs strictly inside the window behave correctly. Expected behavior: the window is inclusive at both ends, as the expansion API documents.

Repository files:
content: # Present so pytest adds the repository root to sys.path, making `import src`
# resolve from the test suite.

path: conftest.py

content: python-dateutil==2.9.0.post0
six==1.17.0

path: requirements.lock

path: src/__init__.py

content: """Expansion of schedule specs into concrete run datetimes."""

from dateutil.rrule import rrule


def occurrences(spec, window_start, window_end):
    """All run datetimes for ``spec`` within the window.

    The window is inclusive on both ends: a run that lands exactly on
    ``window_start`` or ``window_end`` is part of the window.
    """
    first = window_start.replace(hour=spec.hour, minute=spec.minute, second=0, microsecond=0)
    if first < window_start:
        first = None  # let rrule find the first occurrence inside the window
    rule = rrule(
        spec.frequency,
        dtstart=first or window_start,
        byweekday=spec.weekday,
        byhour=spec.hour,
        byminute=spec.minute,
        bysecond=0,
        until=window_end,
    )
    return [occurrence for occurrence in rule if occurrence < window_end]


def next_run(spec, after, horizon):
    """The first run strictly after ``after`` and no later than ``horizon``."""
    runs = occurrences(spec, after, horizon)
    for run in runs:
        if run > after:
            return run
    return None

path: src/expand.py

content: """Parsing of recurring-schedule specifications.

A spec string looks like ``"daily@09:00"`` or ``"weekly:mon@09:00"``.
"""

from dateutil.rrule import DAILY, FR, MO, SA, SU, TH, TU, WE, WEEKLY

_WEEKDAYS = {"mon": MO, "tue": TU, "wed": WE, "thu": TH, "fri": FR, "sat": SA, "sun": SU}
_FREQUENCIES = {"daily": DAILY, "weekly": WEEKLY}


class ScheduleSpec:
    def __init__(self, frequency, hour, minute, weekday=None):
        self.frequency = frequency
        self.hour = hour
        self.minute = minute
        self.weekday = weekday


def parse_spec(text):
    """Parse ``daily@HH:MM`` or ``weekly:<day>@HH:MM`` into a ScheduleSpec."""
    head, _, clock = text.partition("@")
    if not clock:
        raise ValueError(f"missing @HH:MM in spec: {text!r}")
    hour_s, _, minute_s = clock.partition(":")
    hour, minute = int(hour_s), int(minute_s)
    if not (0 <= hour < 24 and 0 <= minute < 60):
        raise ValueError(f"invalid time in spec: {text!r}")

    freq_name, _, day_name = head.partition(":")
    if freq_name not in _FREQUENCIES:
        raise ValueError(f"unknown frequency in spec: {text!r}")
    weekday = None
    if freq_name == "weekly":
        if day_name not in _WEEKDAYS:
            raise ValueError(f"weekly spec needs a valid day: {text!r}")
        weekday = _WEEKDAYS[day_name]
    return ScheduleSpec(_FREQUENCIES[freq_name], hour, minute, weekday=weekday)

path: src/spec.py

content: """Tests for schedule parsing and expansion."""

from datetime import datetime

import pytest

from src.expand import next_run, occurrences
from src.spec import parse_spec


def test_parse_daily():
    spec = parse_spec("daily@09:30")
    assert (spec.hour, spec.minute, spec.weekday) == (9, 30, None)


def test_parse_weekly():
    spec = parse_spec("weekly:mon@07:15")
    assert spec.weekday is not None
    assert (spec.hour, spec.minute) == (7, 15)


def test_parse_rejects_bad_specs():
    with pytest.raises(ValueError):
        parse_spec("hourly@09:00")
    with pytest.raises(ValueError):
        parse_spec("daily@25:00")


def test_daily_occurrences_inside_window():
    runs = occurrences(
        parse_spec("daily@09:00"),
        datetime(2025, 1, 6, 8, 0),
        datetime(2025, 1, 8, 18, 0),
    )
    assert runs == [
        datetime(2025, 1, 6, 9, 0),
        datetime(2025, 1, 7, 9, 0),
        datetime(2025, 1, 8, 9, 0),
    ]


def test_next_run_mid_window():
    run = next_run(
        parse_spec("daily@09:00"),
        datetime(2025, 1, 6, 10, 0),
        datetime(2025, 1, 8, 18, 0),
    )
    assert run == datetime(2025, 1, 7, 9, 0)

path: tests/public/test_public.py

Reference patch:
--- a/src/expand.py
+++ b/src/expand.py
@@ -21,7 +21,7 @@
         bysecond=0,
         until=window_end,
     )
-    return [occurrence for occurrence in rule if occurrence < window_end]
+    return [occurrence for occurrence in rule if occurrence <= window_end]
 
 
 def next_run(spec, after, horizon):


Hidden acceptance tests (diff):
diff --git a/tests/hidden/test_hidden.py b/tests/hidden/test_hidden.py
new file mode 100644
--- /dev/null
+++ b/tests/hidden/test_hidden.py
@@ -0,0 +1,39 @@
+"""Hidden evaluation tests. Never agent-visible (DEC-015).
+
+FAIL_TO_PASS: on the buggy fixture, a run landing exactly on the window end
+is dropped (exclusive comparison against the documented-inclusive window),
+so all three tests fail; the corrected boundary passes all three.
+"""
+
+from datetime import datetime
+
+from src.expand import next_run, occurrences
+from src.spec import parse_spec
+
+
+def test_daily_run_on_window_end_included():
+    runs = occurrences(
+        parse_spec("daily@09:00"),
+        datetime(2025, 1, 6, 8, 0),
+        datetime(2025, 1, 8, 9, 0),  # end lands exactly on a run
+    )
+    assert len(runs) == 3
+    assert runs[-1] == datetime(2025, 1, 8, 9, 0)
+
+
+def test_weekly_final_run_on_end_date():
+    runs = occurrences(
+        parse_spec("weekly:mon@09:00"),
+        datetime(2025, 1, 6, 8, 0),   # Monday
+        datetime(2025, 1, 20, 9, 0),  # Monday, exactly at run time
+    )
+    assert datetime(2025, 1, 20, 9, 0) in runs
+
+
+def test_next_run_at_horizon_boundary():
+    run = next_run(
+        parse_spec("daily@09:00"),
+        datetime(2025, 1, 7, 10, 0),
+        datetime(2025, 1, 8, 9, 0),  # horizon is exactly the next run
+    )
+    assert run == datetime(2025, 1, 8, 9, 0)