Files
aiturk-hermes-ide/tests/cron/test_terminal_job_rearm.py
T

246 lines
8.8 KiB
Python

"""Behavioral coverage for terminal cron jobs and explicit one-shot re-arm."""
from datetime import datetime, timedelta, timezone
import copy
from unittest import mock
import pytest
from cron.jobs import (
advance_next_run,
advance_next_runs,
claim_job_for_fire,
create_job,
get_due_jobs,
get_job,
load_jobs,
mark_job_run,
rearm_oneshot,
resume_job,
save_jobs,
trigger_job,
update_job,
)
@pytest.fixture()
def tmp_cron_dir(tmp_path, monkeypatch):
monkeypatch.setattr("cron.jobs.CRON_DIR", tmp_path / "cron")
monkeypatch.setattr("cron.jobs.JOBS_FILE", tmp_path / "cron" / "jobs.json")
monkeypatch.setattr("cron.jobs.OUTPUT_DIR", tmp_path / "cron" / "output")
return tmp_path
def test_completed_oneshot_trigger_is_refused_and_disk_record_is_unchanged(tmp_cron_dir):
job = create_job("done", "in 30m", name="done", repeat=1)
mark_job_run(job["id"], success=True)
before = copy.deepcopy(load_jobs())
with pytest.raises(ValueError, match="terminal"):
trigger_job(job["id"])
assert load_jobs() == before
assert get_job(job["id"]) == before[0]
def test_exhausted_recurring_job_trigger_is_refused(tmp_cron_dir):
job = create_job("done", "every 1h", repeat=1)
mark_job_run(job["id"], success=True)
with pytest.raises(ValueError, match="terminal"):
trigger_job(job["id"])
def test_wedged_claimed_oneshot_remains_triggerable(tmp_cron_dir):
now = datetime.now(timezone.utc)
job = create_job("wedged", "in 30m", repeat=2)
record = get_job(job["id"])
record.update({
"run_claim": {"at": now.isoformat(), "by": "dead-worker"},
"state": "scheduled",
"enabled": True,
"next_run_at": (now - timedelta(minutes=1)).isoformat(),
})
save_jobs([record])
triggered = trigger_job(job["id"])
assert triggered["state"] == "scheduled"
assert triggered["enabled"] is True
def test_paused_job_run_override_remains_allowed(tmp_cron_dir):
job = create_job("paused", "every 1h")
from cron.jobs import pause_job
pause_job(job["id"])
triggered = trigger_job(job["id"])
assert triggered["state"] == "scheduled"
assert triggered["enabled"] is True
def test_terminal_jobs_are_not_due_or_advanced(tmp_cron_dir):
job = create_job("done", "every 1h", repeat=1)
mark_job_run(job["id"], success=True)
before = copy.deepcopy(load_jobs())
assert get_due_jobs() == []
assert advance_next_run(job["id"]) is False
assert load_jobs() == before
def test_terminal_refusal_survives_reload(tmp_cron_dir):
job = create_job("done", "in 30m", repeat=1)
mark_job_run(job["id"], success=True)
before = copy.deepcopy(load_jobs())
assert get_job(job["id"])["state"] == "completed"
with pytest.raises(ValueError):
trigger_job(job["id"])
assert load_jobs() == before
def test_update_cannot_reactivate_terminal_record(tmp_cron_dir):
job = create_job("done", "in 30m", repeat=1)
mark_job_run(job["id"], success=True)
with pytest.raises(ValueError, match="terminal"):
update_job(job["id"], {"enabled": True})
with pytest.raises(ValueError, match="terminal"):
update_job(job["id"], {"schedule": "every 1h"})
def test_rearm_completed_oneshot_restores_schedule_and_preserves_history(tmp_cron_dir):
from cron.jobs import rearm_oneshot
job = create_job("done", "in 30m", repeat=3)
mark_job_run(job["id"], success=True)
finished = get_job(job["id"])
run_at = (datetime.now(timezone.utc) + timedelta(minutes=5)).isoformat()
rearmed = rearm_oneshot(job["id"], run_at)
assert rearmed["schedule"]["kind"] == "once"
assert rearmed["repeat"]["times"] == 3
assert rearmed["repeat"]["completed"] == 0
assert rearmed["state"] == "scheduled"
assert rearmed["enabled"] is True
assert rearmed["next_run_at"] == rearmed["schedule"]["run_at"]
assert rearmed["last_run_at"] == finished["last_run_at"]
assert rearmed["last_status"] == finished["last_status"]
def test_rearm_refuses_recurring_and_live_claim(tmp_cron_dir):
from cron.jobs import rearm_oneshot
recurring = create_job("recurring", "every 1h")
future = (datetime.now(timezone.utc) + timedelta(minutes=5)).isoformat()
with pytest.raises(ValueError, match="one-shot"):
rearm_oneshot(recurring["id"], future)
oneshot = create_job("claimed", "in 30m")
record = get_job(oneshot["id"])
record["run_claim"] = {"at": datetime.now(timezone.utc).isoformat(), "by": "live"}
save_jobs([record])
with pytest.raises(ValueError, match="claim"):
rearm_oneshot(oneshot["id"], future)
class TestRecurringJobStuckInErrorStateIsRecoverable:
"""A recurring (cron/interval) job that could not compute its next
occurrence is marked ``state=error`` but left ``enabled=True`` — issue
#16265's invariant that recurring jobs must never be silently disabled.
``is_terminal_job()`` previously treated ``state=error`` identically to
``state=completed`` at every call site, which blocked BOTH the due-scan's
own ``next_run_at`` self-heal AND every manual recovery path
(``resume_job``, ``claim_job_for_fire``, ``advance_next_runs``) — wedging
the job forever with no exit except deleting and recreating it. These
tests pin the fix: an error-state recurring job stays recoverable through
every one of those paths, while a genuinely terminal ``state=completed``
job (covered by the tests above) remains blocked through all of them.
"""
@staticmethod
def _force_error_state(job_id):
"""Reproduce the exact state _mark_job_run_locked produces when
compute_next_run() fails for a recurring job (e.g. croniter
missing): state=error, enabled stays True, next_run_at=None."""
with mock.patch("cron.jobs.compute_next_run", return_value=None):
mark_job_run(job_id, success=True)
def test_due_scan_self_heals_next_run_at(self, tmp_cron_dir):
job = create_job("recurring", "every 5m")
self._force_error_state(job["id"])
stuck = get_job(job["id"])
assert stuck["state"] == "error"
assert stuck["enabled"] is True
assert stuck["next_run_at"] is None
# compute_next_run works again on the next tick (the transient
# issue resolved) — the due-scan must recompute next_run_at even
# though the persisted record is still state=error.
get_due_jobs()
healed = get_job(job["id"])
assert healed["next_run_at"] is not None, (
"due-scan self-heal never reached an error-state recurring job"
)
def test_resume_job_recovers_error_state(self, tmp_cron_dir):
job = create_job("recurring", "every 5m")
self._force_error_state(job["id"])
resumed = resume_job(job["id"])
assert resumed is not None, "resume_job must not raise on state=error"
assert resumed["state"] == "scheduled"
assert resumed["enabled"] is True
assert resumed["next_run_at"] is not None
def test_claim_job_for_fire_recovers_error_state(self, tmp_cron_dir):
job = create_job("recurring", "every 5m")
self._force_error_state(job["id"])
claimed = claim_job_for_fire(job["id"], return_job=True)
assert isinstance(claimed, dict), (
"claim_job_for_fire must not refuse an error-state recurring "
"job — it still has future occurrences"
)
assert claimed["fire_claim"] is not None
def test_advance_next_runs_recovers_error_state(self, tmp_cron_dir):
job = create_job("recurring", "every 5m")
self._force_error_state(job["id"])
advanced = advance_next_runs([job["id"]])
assert advanced == 1, (
"advance_next_runs must still pre-advance an error-state "
"recurring job's next_run_at before it fires (crash-safety)"
)
def test_pause_job_now_works_on_error_state(self, tmp_cron_dir):
from cron.jobs import pause_job
job = create_job("recurring", "every 5m")
self._force_error_state(job["id"])
paused = pause_job(job["id"])
assert paused is not None, "pause_job must not raise on state=error"
assert paused["state"] == "paused"
assert paused["enabled"] is False
def test_completed_oneshot_still_blocked_on_every_path(self, tmp_cron_dir):
"""Control: the fix must not weaken protection for a genuinely
terminal state=completed job — only state=error recurring jobs are
exempted."""
job = create_job("done", "in 30m", repeat=1)
mark_job_run(job["id"], success=True)
assert get_job(job["id"])["state"] == "completed"
with pytest.raises(ValueError, match="terminal"):
resume_job(job["id"])
assert claim_job_for_fire(job["id"], return_job=True) is False
assert advance_next_runs([job["id"]]) == 0
assert get_due_jobs() == []