diff --git a/tests/cron/test_cron_service.py b/tests/cron/test_cron_service.py index f1956d8d2..4aa7fc06d 100644 --- a/tests/cron/test_cron_service.py +++ b/tests/cron/test_cron_service.py @@ -479,3 +479,49 @@ def test_update_job_sentinel_channel_and_to(tmp_path) -> None: assert isinstance(result, CronJob) assert result.payload.channel is None assert result.payload.to is None + + +@pytest.mark.asyncio +async def test_list_jobs_during_on_job_does_not_cause_stale_reload(tmp_path) -> None: + """Regression: if the bot calls list_jobs (which reloads from disk) during + on_job execution, the in-memory next_run_at_ms update must not be lost. + Previously this caused an infinite re-trigger loop.""" + store_path = tmp_path / "cron" / "jobs.json" + execution_count = 0 + + async def on_job_that_lists(job): + nonlocal execution_count + execution_count += 1 + # Simulate the bot calling cron(action=list) mid-execution + service.list_jobs() + + service = CronService(store_path, on_job=on_job_that_lists, max_sleep_ms=100) + await service.start() + + # Add two jobs scheduled in the past so they're immediately due + now_ms = int(time.time() * 1000) + for name in ("job-a", "job-b"): + service.add_job( + name=name, + schedule=CronSchedule(kind="every", every_ms=3_600_000), + message="test", + ) + # Force next_run to the past so _on_timer picks them up + for job in service._store.jobs: + job.state.next_run_at_ms = now_ms - 1000 + service._save_store() + service._arm_timer() + + # Let the timer fire once + await asyncio.sleep(0.3) + service.stop() + + # Each job should have run exactly once, not looped + assert execution_count == 2 + + # Verify next_run_at_ms was persisted correctly (in the future) + raw = json.loads(store_path.read_text()) + for j in raw["jobs"]: + next_run = j["state"]["nextRunAtMs"] + assert next_run is not None + assert next_run > now_ms, f"Job '{j['name']}' next_run should be in the future"