fix(mcp): list_maintenance_plans reports only a slot that fired as last_slot_at (#5863)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m27s
CI / integration (push) Successful in 1m32s
CI / publish (push) Successful in 1m6s

A new plan's last_slot_at holds the moment it was made, so its first
slot is the next one; the tool returned that moment, a Wednesday
evening for a Sunday plan, beside latest_run: null. cadence.fired_slot
returns it only when it falls on one of the plan's slots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 16:00:48 -04:00
co-authored by Claude Opus 5.5
parent 398312b8be
commit 22da64a284
3 changed files with 23 additions and 2 deletions
+10
View File
@@ -55,6 +55,16 @@ def last_slot(plan, now: datetime) -> datetime:
return slot.astimezone(timezone.utc)
def fired_slot(plan) -> datetime | None:
"""The last slot that fired, or None while none has. A new plan's
last_slot_at holds the moment it was made, so its first slot is the
next one rather than one already past; that moment is no slot (#5863)."""
at = plan.last_slot_at
if at is None:
return None
return at if last_slot(plan, at) == at else None
def next_slot(plan, now: datetime) -> datetime:
return last_slot(plan, now) + timedelta(days=7)
+3 -2
View File
@@ -136,7 +136,8 @@ async def list_maintenance_plans() -> dict:
A run updates the group's hosts one at a time; `latest_run` says how far
it got (`hosts_done` of `hosts`), which hosts it passed over and why
(`skipped`), and how it ended. get_maintenance_job shows one host's job.
(`skipped`), and how it ended. `last_slot_at` is the last scheduled slot
that fired, null until one has. get_maintenance_job shows one host's job.
Plans are created and edited on the Maintenance page.
"""
from steward.models.ansible_inventory import AnsibleGroup
@@ -164,7 +165,7 @@ async def list_maintenance_plans() -> dict:
"weekday": cadence.WEEKDAYS[plan.weekday], "at_time": plan.at_time,
"time_zone": plan.time_zone, "window_minutes": plan.window_minutes,
"reboot_policy": plan.reboot_policy, "enabled": plan.enabled,
"next_slot_at": iso(nxt), "last_slot_at": iso(plan.last_slot_at),
"next_slot_at": iso(nxt), "last_slot_at": iso(cadence.fired_slot(plan)),
"latest_run": None,
}
if run:
+10
View File
@@ -21,6 +21,16 @@ def _plan(**kw):
return NS(**base)
def test_a_plan_that_has_not_fired_has_no_last_slot():
"""#5863: a new plan's last_slot_at is the moment it was made, a Wednesday
evening for a Sunday plan; only a slot that fired is reported."""
made = _plan(time_zone="America/New_York", last_slot_at=datetime(2026, 10, 14, 23, 54, 58, tzinfo=timezone.utc))
assert c.fired_slot(made) is None
fired = datetime(2026, 10, 18, 7, 0, tzinfo=timezone.utc) # Sunday 03:00 New York
assert c.fired_slot(_plan(time_zone="America/New_York", last_slot_at=fired)) == fired
assert c.fired_slot(_plan()) is None
def test_the_last_slot_is_the_latest_weekday_and_time_in_the_plans_zone():
assert c.last_slot(_plan(), NOW) == datetime(2026, 10, 11, 3, 0, tzinfo=timezone.utc)
# 03:00 in New York is 07:00 UTC in October (EDT)