diff --git a/raven/cli/_cron_handler.py b/raven/cli/_cron_handler.py index 91a27d4..01e8860 100644 --- a/raven/cli/_cron_handler.py +++ b/raven/cli/_cron_handler.py @@ -151,10 +151,11 @@ def make_on_cron_job( fires AND Sentinel proactively reminds at 5/22). ``system_events`` / ``wake`` are optional. When wired (gateway path), - each completed or failed cron run enqueues a system event and requests - an early heartbeat tick, so the main heartbeat session learns what - happened in the isolated ``cron:`` session and can decide on - follow-ups. + a failed cron run or a successful silent run enqueues a system event and + requests an early heartbeat tick, so the main heartbeat session learns + what happened in the isolated ``cron:`` session and can decide on + follow-ups. A successful user-facing run is already delivered directly, + so it must not be re-enqueued for Heartbeat to deliver a second time. Only effective for jobs executed in this process — a CLI test-fire runs in its own process and cannot reach the gateway's queue. """ @@ -246,9 +247,11 @@ async def on_cron_job(job: "CronJob") -> str | None: if sentinel_runner is not None: _record_cron_dispatch_to_ledger(sentinel_runner, job) - # Event wake: let the main heartbeat session learn what this isolated cron - # run produced (and end its sleep early). - if system_events is not None and wake is not None: + # Event wake: silent jobs need the main heartbeat session to learn what + # this isolated cron run produced. A delivering job already reached the + # user through the hub/broadcast path; re-enqueuing its response would let + # Heartbeat deliver the same reminder a second time. + if system_events is not None and wake is not None and not job.payload.deliver: _emit_cron_event(system_events, wake, job, (response or "(no response)").strip(), failed=False) # Broadcast a multi-target delivering job to every resolved target: the hub diff --git a/tests/test_cli_cron_handler.py b/tests/test_cli_cron_handler.py index b73524e..b36bd2e 100644 --- a/tests/test_cli_cron_handler.py +++ b/tests/test_cli_cron_handler.py @@ -362,10 +362,9 @@ def _make_config(): # ───────────────────────────────────────────────────────────────────── -# Spine read-back: a delivering single-target job runs as a CRON turn; the -# reply is read back from readback_texts for the system event (the submitter -# cannot pass run_turn's text_sink — the gateway's capturing runner stores it -# before result() resolves). +# Spine read-back: a silent job runs as a CRON turn; the reply is read back from +# readback_texts for the system event (the submitter cannot pass run_turn's +# text_sink — the gateway's capturing runner stores it before result() resolves). # ───────────────────────────────────────────────────────────────────── @@ -403,9 +402,9 @@ def _submit(req): wake=wake, ) - await handler(_make_job(channel="telegram", to="c1", name="t1")) + await handler(_make_job(channel="cli", deliver=False, name="t1")) - # Single target → rides the hub; no explicit broadcast post. + # Silent source is ephemeral; no explicit user delivery occurs. fake_hub.post.assert_not_awaited() assert captured["req"].origin is Origin.CRON assert captured["req"].conversation == "cron:job_t1" @@ -415,6 +414,42 @@ def _submit(req): assert "cron:job_t1" not in readback_texts +async def test_delivered_cron_does_not_enqueue_duplicate_heartbeat_event( + fake_agent, + fake_hub, + fake_session_mgr, + patch_cron_config, +): + """A user-facing cron reply already rode the hub and must not be resent.""" + patch_cron_config(["*"]) + channel_manager = SimpleNamespace(enabled_channels=["telegram"]) + system_events = MagicMock() + wake = MagicMock() + readback_texts: dict[str, str] = {} + + class _Handle: + async def result(self): + readback_texts["cron:job_t1"] = "already delivered reminder" + return None + + handler = make_on_cron_job( + fake_agent, + fake_hub, + submit=lambda req: _Handle(), + readback_texts=readback_texts, + channel_manager=channel_manager, + session_manager=fake_session_mgr, + system_events=system_events, + wake=wake, + ) + + await handler(_make_job(channel="telegram", to="c1", name="t1")) + + system_events.enqueue.assert_not_called() + wake.request_wake_now.assert_not_called() + assert "cron:job_t1" not in readback_texts + + async def test_spine_path_no_reply_falls_back_to_no_response( fake_agent, fake_hub, @@ -442,7 +477,7 @@ async def result(self): wake=wake, ) - await handler(_make_job(channel="telegram", to="c1", name="t2")) + await handler(_make_job(channel="cli", deliver=False, name="t2")) system_events.enqueue.assert_called_once() assert "(no response)" in system_events.enqueue.call_args.args[0].text