From 22da64a284619f8e231282930ca78e3a8677b36f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 15 Oct 2026 16:00:48 -0400 Subject: [PATCH] fix(mcp): list_maintenance_plans reports only a slot that fired as last_slot_at (#5863) 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 --- plugins/host_agent/cadence.py | 10 ++++++++++ plugins/host_agent/mcp_tools.py | 5 +++-- tests/plugins/host_agent/test_cadence.py | 10 ++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/plugins/host_agent/cadence.py b/plugins/host_agent/cadence.py index 465c15d..d5aa0f9 100644 --- a/plugins/host_agent/cadence.py +++ b/plugins/host_agent/cadence.py @@ -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) diff --git a/plugins/host_agent/mcp_tools.py b/plugins/host_agent/mcp_tools.py index 3058fe2..a91c5b5 100644 --- a/plugins/host_agent/mcp_tools.py +++ b/plugins/host_agent/mcp_tools.py @@ -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: diff --git a/tests/plugins/host_agent/test_cadence.py b/tests/plugins/host_agent/test_cadence.py index 289e45a..4ce7b41 100644 --- a/tests/plugins/host_agent/test_cadence.py +++ b/tests/plugins/host_agent/test_cadence.py @@ -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)