"""Regression tests for #86522 — one-shot run_claim cleared on dispatch failure. ``get_due_jobs`` stamps a ``run_claim`` on one-shot jobs before returning them as due (#59229); ``mark_job_run`` clears it on completion. When dispatch itself fails (interpreter shutdown, execution-creation error, executor submit error) the job never reaches ``mark_job_run`` and the stale claim blocked re-dispatch until the TTL expired (default 30 min) — a precisely-timed one-shot reminder arrived up to 30 minutes late with no error surfaced. The fix (salvaged from PR #87591 by @RelaxJonh) adds ``cron.jobs.clear_run_claim`` and calls it on all three ``_submit_with_guard`` early-exit paths, wrapped best-effort so a failing store can never crash the tick these paths exist to protect. """ from __future__ import annotations import sys from pathlib import Path from unittest.mock import patch import pytest sys.path.insert(0, str(Path(__file__).parent.parent.parent)) import cron.jobs as jobs_mod from cron.jobs import clear_run_claim @pytest.fixture def cron_store(tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" (hermes_home / "cron").mkdir(parents=True) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.setattr(jobs_mod, "HERMES_DIR", hermes_home) monkeypatch.setattr(jobs_mod, "CRON_DIR", hermes_home / "cron") monkeypatch.setattr(jobs_mod, "JOBS_FILE", hermes_home / "cron" / "jobs.json") monkeypatch.setattr(jobs_mod, "OUTPUT_DIR", hermes_home / "cron" / "output") return hermes_home def _make_oneshot(claimed: bool = True) -> dict: job = jobs_mod.create_job(prompt="remind me", schedule="in 30m") if claimed: jobs = jobs_mod.load_jobs() for j in jobs: if j["id"] == job["id"]: j["run_claim"] = {"at": "2026-08-17T10:00:00+00:00", "by": "test:1"} jobs_mod.save_jobs(jobs) return job class TestClearRunClaim: def test_clears_claim_on_oneshot(self, cron_store): job = _make_oneshot(claimed=True) assert clear_run_claim(job["id"]) is True reloaded = [j for j in jobs_mod.load_jobs() if j["id"] == job["id"]][0] assert reloaded.get("run_claim") is None def test_noop_when_already_clear(self, cron_store): job = _make_oneshot(claimed=False) assert clear_run_claim(job["id"]) is False def test_never_touches_recurring_jobs(self, cron_store): job = jobs_mod.create_job(prompt="tick", schedule="every 10m") jobs = jobs_mod.load_jobs() for j in jobs: if j["id"] == job["id"]: j["run_claim"] = {"at": "2026-08-17T10:00:00+00:00", "by": "test:1"} jobs_mod.save_jobs(jobs) assert clear_run_claim(job["id"]) is False reloaded = [j for j in jobs_mod.load_jobs() if j["id"] == job["id"]][0] assert reloaded.get("run_claim") is not None # untouched def test_unknown_job_id_returns_false(self, cron_store): assert clear_run_claim("no-such-job") is False class TestDispatchFailurePathsClearClaim: """Each _submit_with_guard early-exit must clear the one-shot claim so the next healthy tick re-dispatches instead of waiting out the 30-min TTL.""" def _tick_one(self, job): from cron import scheduler as sched with patch.object(sched, "get_due_jobs", return_value=[dict(job)]): return sched.tick(verbose=False, sync=True) def test_interpreter_shutdown_path_clears_claim(self, cron_store): from cron import scheduler as sched job = _make_oneshot(claimed=True) with patch.object(sched, "_interpreter_shutting_down", return_value=True): self._tick_one(job) reloaded = [j for j in jobs_mod.load_jobs() if j["id"] == job["id"]][0] assert reloaded.get("run_claim") is None, ( "shutdown-path dispatch failure must clear run_claim (#86522)" ) def test_execution_creation_failure_clears_claim(self, cron_store): from cron import scheduler as sched job = _make_oneshot(claimed=True) with patch.object(sched, "create_execution", side_effect=RuntimeError("db gone")): self._tick_one(job) reloaded = [j for j in jobs_mod.load_jobs() if j["id"] == job["id"]][0] assert reloaded.get("run_claim") is None assert job["id"] not in sched.get_running_job_ids() def test_submit_failure_clears_claim(self, cron_store): from cron import scheduler as sched job = _make_oneshot(claimed=True) class _ExplodingPool: def submit(self, *a, **k): raise RuntimeError("cannot schedule new futures") pool = _ExplodingPool() with patch.object(sched, "_get_parallel_pool", return_value=pool): self._tick_one(job) reloaded = [j for j in jobs_mod.load_jobs() if j["id"] == job["id"]][0] assert reloaded.get("run_claim") is None assert job["id"] not in sched.get_running_job_ids() def test_clear_failure_is_best_effort_not_fatal(self, cron_store): """A raising clear_run_claim (corrupt store, teardown I/O error) must not crash the tick — these early-exit paths exist to skip cleanly; the claim then simply expires at the TTL.""" from cron import scheduler as sched job = _make_oneshot(claimed=True) with patch.object(sched, "_interpreter_shutting_down", return_value=True), \ patch.object(sched, "clear_run_claim", side_effect=OSError(24, "Too many open files")): n = self._tick_one(job) # must not raise assert n == 0 def test_recurring_dispatch_failure_skips_claim_io(self, cron_store): """Recurring jobs carry no run_claim, so the dispatch-failure paths must not pay clear_run_claim's lock acquisition + full jobs-file read for a guaranteed no-op — the failure paths fire exactly when the process can least afford pointless I/O (shutdown, EMFILE).""" from cron import scheduler as sched job = jobs_mod.create_job(prompt="hourly", schedule="every 1h") with patch.object(sched, "_interpreter_shutting_down", return_value=True), \ patch.object(sched, "clear_run_claim") as mock_clear: self._tick_one(job) mock_clear.assert_not_called()