From 0a8aee587d9c716794ffa518a57773e875f4fa8c Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:29:18 +0530 Subject: [PATCH] fix(gateway): send cron store notices for a symlinked cron/ directory Follow-up to #133283 (greptile review). Store records are keyed by the resolved store path, but the notice sender rebuilt the owning profile from that path's parent. A profile whose cron/ is a symlink to another disk has a foreign parent, so neither its outage nor recovery notice reached any home channel; forget_homes had the same mismatch and never dropped such a departed profile's record. Both now match by resolving each served home's own cron/ path. --- cron/store_health.py | 5 ++--- gateway/cron_store_notices.py | 5 +++-- tests/cron/test_due_scan_save_failure.py | 5 ++++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/cron/store_health.py b/cron/store_health.py index 0b363b3590..cd94acdd4e 100644 --- a/cron/store_health.py +++ b/cron/store_health.py @@ -165,11 +165,10 @@ def outage_covers(cron_dir: Path, due_at: float, grace: float) -> bool: def forget_homes(home_keys) -> None: """Drop the state of stores whose profile home (``hermes_home_key``) this process no longer ticks, so a profile that left this gateway cannot keep the host-wide gauges at writable=0.""" - from hermes_constants import hermes_home_key - keys = set(home_keys) + stores = {_key(Path(home) / "cron") for home in home_keys} # a symlinked cron/ resolves elsewhere with _lock: for records in (_degraded, _recovered): - for store in [s for s in records if hermes_home_key(Path(s).parent) in keys]: + for store in [s for s in records if s in stores]: del records[store] diff --git a/gateway/cron_store_notices.py b/gateway/cron_store_notices.py index d88936f1b8..c9953bc4a3 100644 --- a/gateway/cron_store_notices.py +++ b/gateway/cron_store_notices.py @@ -95,12 +95,13 @@ async def send_cron_store_notice(runner, event: str, record) -> None: fields = record.notice_fields() key = "gateway.cron_store.unwritable" if event == "unwritable" else "gateway.cron_store.recovered" - store_home = hermes_home_key(Path(record.store).parent) + store_key = hermes_home_key(record.store) served_homes = runner._served_profile_homes or {} logger.info("Broadcasting cron store %s notice for %s", event, record.store) for profile, platform, _cfg, home, transport in list(runner._served_home_channel_transports()): served_home = served_homes.get(profile) if profile is not None else None - if hermes_home_key(served_home or get_routing_process_hermes_home()) != store_home: + # record.store is resolved: a cron/ symlinked to another disk has a foreign parent. + if hermes_home_key(Path(served_home or get_routing_process_hermes_home()) / "cron") != store_key: continue # The opt-out is the owning profile's; the launch profile needs no extra scope. scope = _async_profile_runtime_scope(Path(served_home)) if served_home else contextlib.nullcontext() diff --git a/tests/cron/test_due_scan_save_failure.py b/tests/cron/test_due_scan_save_failure.py index 2cb38a130e..3efe077675 100644 --- a/tests/cron/test_due_scan_save_failure.py +++ b/tests/cron/test_due_scan_save_failure.py @@ -380,7 +380,10 @@ def test_unwritable_store_is_shown_in_cron_status_and_announced_once(cron_store, return True home = SimpleNamespace(chat_id="c1", thread_id=None) - runner = SimpleNamespace(_send_home_channel_message=send, _served_profile_homes={"p": cron_store}, + profile = cron_store / "profile" # its cron/ is a symlink to the store on another disk + profile.mkdir() + (profile / "cron").symlink_to(cron_dir, target_is_directory=True) + runner = SimpleNamespace(_send_home_channel_message=send, _served_profile_homes={"p": profile}, _served_home_channel_transports=lambda: iter([("p", "telegram", None, home, object())])) monkeypatch.setattr("hermes_constants.get_routing_process_hermes_home", lambda: cron_store / "launch") # A manual loop clock: the real one-hour "recovered" window only elapses when the test says