Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions raven/cli/_cron_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:<job_id>`` 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:<job_id>`` 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.
"""
Expand Down Expand Up @@ -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
Expand Down
49 changes: 42 additions & 7 deletions tests/test_cli_cron_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
# ─────────────────────────────────────────────────────────────────────


Expand Down Expand Up @@ -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"
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down