mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Systematic prune per AGENTS.md test policy, one pass over every major test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli, cron, tui_gateway, honcho/openviking, root-level): - DELETE: source-reading tests (read_text/getsource on prod files), change-detector tests (exact catalog counts, model-name snapshots, config version literals), mock-echo tests (assert a mock returns what it was told), assertion-free/trivial tests, near-duplicate parametrizations (boundaries + one representative kept), async/sync twin duplicates, cosmetic within-file variations. - KEEP (mandatory): security/redaction/approval guards, message-role alternation invariants, prompt-caching/deterministic-call-id invariants, issue-number regression tests (deduped), E2E tests. - 6 test files deleted outright (script-style/no-assert or fully redundant); conftest.py, fakes/, fixtures/ untouched. - tests/acp/conftest.py added: autouse fixture stubs the live models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server tests performed on every session create — test_server.py 147s → 3.4s, and the tests are now genuinely hermetic. - Sleep-based slowness shrunk where safe (codex_ttfb_watchdog, compression_concurrent_fork, etc.); no wall-clock assertion tightened. Verification: full hermetic suite via scripts/run_tests.sh — 2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall (baseline: 583s wall, 13,564s subprocess CPU).
1071 lines
44 KiB
Python
1071 lines
44 KiB
Python
"""Tests for cron/jobs.py — schedule parsing, job CRUD, and due-job detection."""
|
|
|
|
import threading
|
|
import pytest
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from cron.jobs import (
|
|
parse_duration,
|
|
parse_schedule,
|
|
compute_next_run,
|
|
create_job,
|
|
load_jobs,
|
|
save_jobs,
|
|
get_job,
|
|
list_jobs,
|
|
update_job,
|
|
pause_job,
|
|
resume_job,
|
|
remove_job,
|
|
mark_job_run,
|
|
advance_next_run,
|
|
claim_dispatch,
|
|
heartbeat_run_claim,
|
|
get_due_jobs,
|
|
save_job_output,
|
|
)
|
|
|
|
|
|
# =========================================================================
|
|
# parse_duration
|
|
# =========================================================================
|
|
|
|
class TestParseDuration:
|
|
def test_minutes(self):
|
|
assert parse_duration("30m") == 30
|
|
assert parse_duration("1min") == 1
|
|
assert parse_duration("5mins") == 5
|
|
assert parse_duration("10minute") == 10
|
|
assert parse_duration("120minutes") == 120
|
|
|
|
|
|
def test_invalid_raises(self):
|
|
with pytest.raises(ValueError):
|
|
parse_duration("abc")
|
|
with pytest.raises(ValueError):
|
|
parse_duration("30x")
|
|
with pytest.raises(ValueError):
|
|
parse_duration("")
|
|
with pytest.raises(ValueError):
|
|
parse_duration("m30")
|
|
|
|
|
|
# =========================================================================
|
|
# parse_schedule
|
|
# =========================================================================
|
|
|
|
class TestParseSchedule:
|
|
def test_duration_becomes_once(self):
|
|
result = parse_schedule("30m")
|
|
assert result["kind"] == "once"
|
|
assert "run_at" in result
|
|
# run_at should be a valid ISO timestamp string ~30 minutes from now
|
|
run_at_str = result["run_at"]
|
|
assert isinstance(run_at_str, str)
|
|
run_at = datetime.fromisoformat(run_at_str)
|
|
now = datetime.now().astimezone()
|
|
assert run_at > now
|
|
assert run_at < now + timedelta(minutes=31)
|
|
|
|
def test_every_becomes_interval(self):
|
|
result = parse_schedule("every 2h")
|
|
assert result["kind"] == "interval"
|
|
assert result["minutes"] == 120
|
|
|
|
|
|
def test_cron_expression(self):
|
|
pytest.importorskip("croniter")
|
|
result = parse_schedule("0 9 * * *")
|
|
assert result["kind"] == "cron"
|
|
assert result["expr"] == "0 9 * * *"
|
|
|
|
def test_iso_timestamp(self):
|
|
result = parse_schedule("2030-01-15T14:00:00")
|
|
assert result["kind"] == "once"
|
|
assert "2030-01-15" in result["run_at"]
|
|
|
|
|
|
def test_naive_iso_anchors_to_configured_tz_not_server_local(self, monkeypatch):
|
|
"""A naive ISO timestamp must be interpreted in the CONFIGURED Hermes
|
|
timezone, NOT the server's local timezone (#51021).
|
|
|
|
Regression: when the configured zone differs from the server's local
|
|
zone (common on cloud hosts running UTC), parse_schedule used
|
|
``dt.astimezone()`` (server-local), baking in the wrong offset. The
|
|
due-check compares against ``_hermes_now()`` (configured zone), so the
|
|
stored instant landed hours off the user's wall-clock intent — far
|
|
enough that one-shots never became due. This asserts the parsed offset
|
|
matches the configured-now offset, the invariant that keeps the stored
|
|
instant on the same clock the scheduler checks against.
|
|
"""
|
|
configured_now = datetime(2026, 6, 22, 20, 0, 0, tzinfo=timezone(timedelta(hours=5, minutes=30)))
|
|
monkeypatch.setattr("cron.jobs._hermes_now", lambda: configured_now)
|
|
|
|
result = parse_schedule("2026-06-22T20:07:00") # naive, user wall-clock
|
|
|
|
assert result["kind"] == "once"
|
|
parsed = datetime.fromisoformat(result["run_at"])
|
|
assert parsed.utcoffset() == configured_now.utcoffset()
|
|
# Same wall-clock the user typed, on the configured clock.
|
|
assert parsed.replace(tzinfo=None) == datetime(2026, 6, 22, 20, 7, 0)
|
|
|
|
|
|
# =========================================================================
|
|
# Timezone-divergence regression (#51021)
|
|
# =========================================================================
|
|
|
|
class TestNaiveScheduleTimezoneDivergence:
|
|
"""End-to-end: a one-shot created with a naive recent-past timestamp must
|
|
become due even when the configured Hermes timezone differs from the
|
|
server's local timezone. Before #51021 the naive value was anchored to
|
|
server-local, so the job never fired."""
|
|
|
|
def test_recent_past_oneshot_is_due_under_diverging_tz(self, tmp_cron_dir, monkeypatch):
|
|
# Configured zone: a fixed +05:30 offset. The server's actual local
|
|
# zone is irrelevant to the parse now — that is the whole point.
|
|
configured = timezone(timedelta(hours=5, minutes=30))
|
|
now = datetime(2026, 6, 22, 20, 7, 30, tzinfo=configured)
|
|
monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
|
|
|
|
# 30s ago in the configured wall clock, supplied as a NAIVE string.
|
|
naive_str = (now - timedelta(seconds=30)).replace(tzinfo=None).isoformat()
|
|
job = create_job(prompt="test message", schedule=naive_str, deliver="local")
|
|
|
|
due = get_due_jobs()
|
|
assert any(d["id"] == job["id"] for d in due), (
|
|
f"one-shot should be due; next_run_at={job['next_run_at']}"
|
|
)
|
|
|
|
|
|
# =========================================================================
|
|
# compute_next_run
|
|
# =========================================================================
|
|
|
|
class TestComputeNextRun:
|
|
def test_once_future_returns_time(self):
|
|
future = (datetime.now() + timedelta(hours=1)).isoformat()
|
|
schedule = {"kind": "once", "run_at": future}
|
|
assert compute_next_run(schedule) == future
|
|
|
|
def test_once_recent_past_within_grace_returns_time(self, monkeypatch):
|
|
now = datetime(2026, 3, 18, 4, 22, 3, tzinfo=timezone.utc)
|
|
run_at = "2026-03-18T04:22:00+00:00"
|
|
monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
|
|
|
|
schedule = {"kind": "once", "run_at": run_at}
|
|
|
|
assert compute_next_run(schedule) == run_at
|
|
|
|
|
|
def test_once_with_last_run_returns_none_even_within_grace(self, monkeypatch):
|
|
now = datetime(2026, 3, 18, 4, 22, 3, tzinfo=timezone.utc)
|
|
run_at = "2026-03-18T04:22:00+00:00"
|
|
monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
|
|
|
|
schedule = {"kind": "once", "run_at": run_at}
|
|
|
|
assert compute_next_run(schedule, last_run_at=now.isoformat()) is None
|
|
|
|
def test_interval_first_run(self):
|
|
schedule = {"kind": "interval", "minutes": 60}
|
|
result = compute_next_run(schedule)
|
|
next_dt = datetime.fromisoformat(result)
|
|
# Should be ~60 minutes from now
|
|
assert next_dt > datetime.now().astimezone() + timedelta(minutes=59)
|
|
|
|
|
|
# =========================================================================
|
|
# Job CRUD (with tmp file storage)
|
|
# =========================================================================
|
|
|
|
@pytest.fixture()
|
|
def tmp_cron_dir(tmp_path, monkeypatch):
|
|
"""Redirect cron storage to a temp directory."""
|
|
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
|
|
|
|
|
|
class TestJobCRUD:
|
|
def test_create_and_get(self, tmp_cron_dir):
|
|
job = create_job(prompt="Check server status", schedule="30m")
|
|
assert job["id"]
|
|
assert job["prompt"] == "Check server status"
|
|
assert job["enabled"] is True
|
|
assert job["schedule"]["kind"] == "once"
|
|
|
|
fetched = get_job(job["id"])
|
|
assert fetched is not None
|
|
assert fetched["prompt"] == "Check server status"
|
|
|
|
def test_list_jobs(self, tmp_cron_dir):
|
|
create_job(prompt="Job 1", schedule="every 1h")
|
|
create_job(prompt="Job 2", schedule="every 2h")
|
|
jobs = list_jobs()
|
|
assert len(jobs) == 2
|
|
|
|
|
|
def test_remove_job(self, tmp_cron_dir):
|
|
job = create_job(prompt="Temp job", schedule="30m")
|
|
assert remove_job(job["id"]) is True
|
|
assert get_job(job["id"]) is None
|
|
|
|
|
|
def test_auto_repeat_for_once(self, tmp_cron_dir):
|
|
job = create_job(prompt="One-shot", schedule="1h")
|
|
assert job["repeat"]["times"] == 1
|
|
|
|
def test_rejects_stale_past_one_shot_at_creation(self, tmp_cron_dir, monkeypatch):
|
|
now = datetime(2026, 3, 18, 4, 30, 0, tzinfo=timezone.utc)
|
|
monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
|
|
stale = (now - timedelta(minutes=5)).isoformat()
|
|
|
|
with pytest.raises(ValueError, match="past and cannot be scheduled"):
|
|
create_job(prompt="Too late", schedule=stale)
|
|
|
|
assert load_jobs() == []
|
|
|
|
|
|
def test_default_delivery_origin(self, tmp_cron_dir):
|
|
job = create_job(
|
|
prompt="Test", schedule="30m",
|
|
origin={"platform": "telegram", "chat_id": "123"},
|
|
)
|
|
assert job["deliver"] == "origin"
|
|
|
|
|
|
class TestUpdateJob:
|
|
def test_update_name(self, tmp_cron_dir):
|
|
job = create_job(prompt="Check server status", schedule="every 1h", name="Old Name")
|
|
assert job["name"] == "Old Name"
|
|
updated = update_job(job["id"], {"name": "New Name"})
|
|
assert updated is not None
|
|
assert isinstance(updated, dict)
|
|
assert updated["name"] == "New Name"
|
|
# Verify other fields are preserved
|
|
assert updated["prompt"] == "Check server status"
|
|
assert updated["id"] == job["id"]
|
|
assert updated["schedule"] == job["schedule"]
|
|
# Verify persisted to disk
|
|
fetched = get_job(job["id"])
|
|
assert fetched["name"] == "New Name"
|
|
|
|
|
|
class TestPauseResumeJob:
|
|
def test_pause_sets_state(self, tmp_cron_dir):
|
|
job = create_job(prompt="Pause me", schedule="every 1h")
|
|
paused = pause_job(job["id"], reason="user paused")
|
|
assert paused is not None
|
|
assert paused["enabled"] is False
|
|
assert paused["state"] == "paused"
|
|
assert paused["paused_reason"] == "user paused"
|
|
|
|
|
|
def test_resume_rejects_past_oneshot(self, tmp_cron_dir, monkeypatch):
|
|
"""Resuming a paused one-shot whose time is now in the past must raise
|
|
ValueError — the revived job would silently never fire."""
|
|
now = datetime(2026, 7, 6, 12, 0, 0, tzinfo=timezone.utc)
|
|
monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
|
|
# Create directly — bypass create_job's past-oneshot guard so we can
|
|
# test the resume path independently.
|
|
job = {
|
|
"id": "test-resume-past",
|
|
"name": "test-resume-past",
|
|
"prompt": "Past one-shot",
|
|
"schedule": {"kind": "once", "run_at": (now - timedelta(minutes=5)).isoformat(), "display": "once"},
|
|
"repeat": {"times": 1, "completed": 0},
|
|
"enabled": False,
|
|
"state": "paused",
|
|
"paused_at": now.isoformat(),
|
|
"paused_reason": "test",
|
|
"next_run_at": None,
|
|
"last_run_at": None,
|
|
"last_status": None,
|
|
"last_error": None,
|
|
"last_delivery_error": None,
|
|
"created_at": (now - timedelta(hours=1)).isoformat(),
|
|
"deliver": "local",
|
|
}
|
|
save_jobs([job])
|
|
with pytest.raises(ValueError, match="in the past"):
|
|
resume_job("test-resume-past")
|
|
|
|
|
|
class TestResolveJobRef:
|
|
"""Name-based job lookup for CLI/tool callers (PR #2627, @buntingszn)."""
|
|
|
|
def test_resolve_by_exact_id(self, tmp_cron_dir):
|
|
from cron.jobs import resolve_job_ref
|
|
|
|
job = create_job(prompt="A", schedule="1h", name="alpha")
|
|
assert resolve_job_ref(job["id"])["id"] == job["id"]
|
|
|
|
|
|
def test_mutations_refuse_ambiguous_name(self, tmp_cron_dir):
|
|
"""pause/resume/trigger/remove must refuse to act on an ambiguous name."""
|
|
from cron.jobs import AmbiguousJobReference, trigger_job
|
|
|
|
create_job(prompt="A", schedule="1h", name="dup")
|
|
create_job(prompt="B", schedule="1h", name="dup")
|
|
for fn in (pause_job, resume_job, trigger_job):
|
|
with pytest.raises(AmbiguousJobReference):
|
|
fn("dup")
|
|
with pytest.raises(AmbiguousJobReference):
|
|
remove_job("dup")
|
|
|
|
|
|
class TestMarkJobRun:
|
|
def test_increments_completed(self, tmp_cron_dir):
|
|
job = create_job(prompt="Test", schedule="every 1h")
|
|
mark_job_run(job["id"], success=True)
|
|
updated = get_job(job["id"])
|
|
assert updated["repeat"]["completed"] == 1
|
|
assert updated["last_status"] == "ok"
|
|
|
|
def test_repeat_limit_removes_job(self, tmp_cron_dir):
|
|
job = create_job(prompt="Once", schedule="30m", repeat=1)
|
|
mark_job_run(job["id"], success=True)
|
|
# Job should be removed after hitting repeat limit
|
|
assert get_job(job["id"]) is None
|
|
|
|
|
|
def test_error_status(self, tmp_cron_dir):
|
|
job = create_job(prompt="Fail", schedule="every 1h")
|
|
mark_job_run(job["id"], success=False, error="timeout")
|
|
updated = get_job(job["id"])
|
|
assert updated["last_status"] == "error"
|
|
assert updated["last_error"] == "timeout"
|
|
|
|
def test_delivery_error_tracked_separately(self, tmp_cron_dir):
|
|
"""Agent succeeds but delivery fails — both tracked independently."""
|
|
job = create_job(prompt="Report", schedule="every 1h")
|
|
mark_job_run(job["id"], success=True, delivery_error="platform 'telegram' not configured")
|
|
updated = get_job(job["id"])
|
|
assert updated["last_status"] == "ok"
|
|
assert updated["last_error"] is None
|
|
assert updated["last_delivery_error"] == "platform 'telegram' not configured"
|
|
|
|
|
|
def test_recurring_cron_not_disabled_when_croniter_missing(self, tmp_cron_dir, monkeypatch):
|
|
"""Regression test for issue #16265.
|
|
|
|
If the gateway runs in an env where `croniter` went missing after a
|
|
recurring cron job was persisted, `compute_next_run()` returns None.
|
|
`mark_job_run()` must NOT treat that as terminal completion — the job
|
|
has to stay enabled with state=error so the user notices, rather than
|
|
silently flipping to enabled=false, state=completed.
|
|
"""
|
|
pytest.importorskip("croniter") # need it to create the job
|
|
job = create_job(prompt="Recurring", schedule="0 7,15,23 * * *")
|
|
assert job["schedule"]["kind"] == "cron"
|
|
|
|
# Simulate the runtime env having lost croniter between job creation
|
|
# and this run.
|
|
monkeypatch.setattr("cron.jobs.HAS_CRONITER", False)
|
|
|
|
mark_job_run(job["id"], success=True)
|
|
|
|
updated = get_job(job["id"])
|
|
assert updated is not None, "recurring cron job was deleted"
|
|
assert updated["enabled"] is True, (
|
|
"recurring cron job was disabled despite croniter-missing being "
|
|
"a runtime dep issue, not a terminal completion"
|
|
)
|
|
assert updated["state"] == "error"
|
|
assert updated["state"] != "completed"
|
|
assert updated["next_run_at"] is None
|
|
assert updated["last_error"]
|
|
assert "croniter" in updated["last_error"].lower()
|
|
|
|
|
|
class TestAdvanceNextRun:
|
|
"""Tests for advance_next_run() — crash-safety for recurring jobs."""
|
|
|
|
def test_advances_interval_job(self, tmp_cron_dir):
|
|
"""Interval jobs should have next_run_at bumped to the next future occurrence."""
|
|
job = create_job(prompt="Recurring check", schedule="every 1h")
|
|
# Force next_run_at to 5 minutes ago (i.e. the job is due)
|
|
jobs = load_jobs()
|
|
old_next = (datetime.now() - timedelta(minutes=5)).isoformat()
|
|
jobs[0]["next_run_at"] = old_next
|
|
save_jobs(jobs)
|
|
|
|
result = advance_next_run(job["id"])
|
|
assert result is True
|
|
|
|
updated = get_job(job["id"])
|
|
from cron.jobs import _ensure_aware, _hermes_now
|
|
new_next_dt = _ensure_aware(datetime.fromisoformat(updated["next_run_at"]))
|
|
assert new_next_dt > _hermes_now(), "next_run_at should be in the future after advance"
|
|
|
|
|
|
def test_skips_oneshot_job(self, tmp_cron_dir):
|
|
"""One-shot jobs should NOT be advanced — they need to retry on restart."""
|
|
job = create_job(prompt="Run once", schedule="30m")
|
|
original_next = get_job(job["id"])["next_run_at"]
|
|
|
|
result = advance_next_run(job["id"])
|
|
assert result is False
|
|
|
|
updated = get_job(job["id"])
|
|
assert updated["next_run_at"] == original_next, "one-shot next_run_at should be unchanged"
|
|
|
|
|
|
def test_crash_safety_scenario(self, tmp_cron_dir):
|
|
"""Simulate the crash-loop scenario: after advance, the job should NOT be due."""
|
|
job = create_job(prompt="Crash test", schedule="every 1h")
|
|
# Force next_run_at to 5 minutes ago (job is due)
|
|
jobs = load_jobs()
|
|
jobs[0]["next_run_at"] = (datetime.now() - timedelta(minutes=5)).isoformat()
|
|
save_jobs(jobs)
|
|
|
|
# Job should be due before advance
|
|
due_before = get_due_jobs()
|
|
assert len(due_before) == 1
|
|
|
|
# Advance (simulating what tick() does before run_job)
|
|
advance_next_run(job["id"])
|
|
|
|
# Now the job should NOT be due (simulates restart after crash)
|
|
due_after = get_due_jobs()
|
|
assert len(due_after) == 0, "Job should not be due after advance_next_run"
|
|
|
|
|
|
class TestGetDueJobs:
|
|
def test_past_due_within_window_returned(self, tmp_cron_dir):
|
|
"""Jobs within the dynamic grace window are still considered due (not stale).
|
|
|
|
For an hourly job, grace = 30 min (half the period, clamped to [120s, 2h]).
|
|
"""
|
|
job = create_job(prompt="Due now", schedule="every 1h")
|
|
# Force next_run_at to 10 minutes ago (within the 30-min grace for hourly)
|
|
jobs = load_jobs()
|
|
jobs[0]["next_run_at"] = (datetime.now() - timedelta(minutes=10)).isoformat()
|
|
save_jobs(jobs)
|
|
|
|
due = get_due_jobs()
|
|
assert len(due) == 1
|
|
assert due[0]["id"] == job["id"]
|
|
|
|
def test_stale_past_due_runs_once_and_fast_forwards(self, tmp_cron_dir):
|
|
"""Recurring jobs past their grace window run once now and fast-forward next_run_at.
|
|
|
|
For an hourly job, grace = 30 min. Setting 35 min late exceeds the window.
|
|
The job should be returned as due (execute once) with next_run_at in the future.
|
|
"""
|
|
job = create_job(prompt="Stale", schedule="every 1h")
|
|
# Force next_run_at to 35 minutes ago (beyond the 30-min grace for hourly)
|
|
jobs = load_jobs()
|
|
jobs[0]["next_run_at"] = (datetime.now() - timedelta(minutes=35)).isoformat()
|
|
save_jobs(jobs)
|
|
|
|
due = get_due_jobs()
|
|
# Job is returned as due — execute once now instead of skipping
|
|
assert len(due) == 1
|
|
assert due[0]["id"] == job["id"]
|
|
# next_run_at should be fast-forwarded to the future (accumulated slots skipped)
|
|
updated = get_job(job["id"])
|
|
from cron.jobs import _ensure_aware, _hermes_now
|
|
next_dt = _ensure_aware(datetime.fromisoformat(updated["next_run_at"]))
|
|
assert next_dt > _hermes_now()
|
|
|
|
|
|
def test_idless_job_does_not_crash_or_block_sibling_jobs(self, tmp_cron_dir):
|
|
"""A job missing its 'id' key must not crash the tick or freeze siblings.
|
|
|
|
Regression: jobs authored by a direct jobs.json edit (bypassing
|
|
create_job) sometimes used the key 'job_id' instead of 'id'. The logging
|
|
helpers evaluated ``job.get("name", job["id"])`` -- Python evaluates the
|
|
default argument ``job["id"]`` eagerly, so an id-less job raised
|
|
``KeyError: 'id'`` mid-tick. That exception aborted
|
|
``_get_due_jobs_locked()`` BEFORE ``save_jobs()`` ran, so every healthy
|
|
job's fast-forwarded next_run_at was computed in memory then discarded --
|
|
the whole profile's scheduler froze in a per-minute loop.
|
|
"""
|
|
healthy = create_job(prompt="Healthy", schedule="every 1h")
|
|
|
|
jobs = load_jobs()
|
|
# Push the healthy job beyond its grace window so the fast-forward path
|
|
# (one of the id-less-crash sites) runs.
|
|
jobs[0]["next_run_at"] = (datetime.now() - timedelta(minutes=35)).isoformat()
|
|
# A malformed record: no 'id' key, mirroring the real corruption.
|
|
jobs.append({
|
|
"name": "idless-job",
|
|
"schedule": {"kind": "cron", "expr": "0 4 * * *"},
|
|
"enabled": True,
|
|
"no_agent": True,
|
|
"next_run_at": None,
|
|
})
|
|
save_jobs(jobs)
|
|
|
|
# Must not raise KeyError.
|
|
due = get_due_jobs()
|
|
|
|
# The healthy sibling is still discovered despite the malformed neighbor.
|
|
assert any(d.get("id") == healthy["id"] for d in due)
|
|
|
|
|
|
def test_long_execution_does_not_perpetually_defer(self, tmp_cron_dir, monkeypatch):
|
|
"""#33315: a recurring job whose runtime exceeds interval+grace must still
|
|
run once when the tick comes back, not skip forever.
|
|
|
|
Reproduces the production loop: a 5-min interval job whose previous run
|
|
overran the interval, leaving next_run_at ~11 min in the past — beyond
|
|
the 150s grace for a 5m interval. The job must be returned as due (run
|
|
once) AND have next_run_at fast-forwarded (so accumulated missed slots
|
|
don't all fire)."""
|
|
from cron.jobs import _ensure_aware, _hermes_now
|
|
job = create_job(prompt="Long job", schedule="every 5m")
|
|
jobs = load_jobs()
|
|
# 11 minutes ago: > grace (150s for a 5m interval) — the "still running" miss.
|
|
stale = (_hermes_now() - timedelta(minutes=11)).isoformat()
|
|
jobs[0]["next_run_at"] = stale
|
|
jobs[0]["last_run_at"] = (_hermes_now() - timedelta(minutes=1)).isoformat()
|
|
save_jobs(jobs)
|
|
|
|
due = get_due_jobs()
|
|
assert [j["id"] for j in due] == [job["id"]], "long-execution job was skipped (perpetual-defer bug)"
|
|
# next_run_at fast-forwarded into the future (no burst of missed slots).
|
|
nxt = _ensure_aware(datetime.fromisoformat(get_job(job["id"])["next_run_at"]))
|
|
assert nxt > _hermes_now()
|
|
|
|
|
|
def test_broken_recent_one_shot_without_next_run_is_recovered(self, tmp_cron_dir, monkeypatch):
|
|
now = datetime(2026, 3, 18, 4, 22, 30, tzinfo=timezone.utc)
|
|
monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
|
|
|
|
run_at = "2026-03-18T04:22:00+00:00"
|
|
save_jobs(
|
|
[{
|
|
"id": "oneshot-recover",
|
|
"name": "Recover me",
|
|
"prompt": "Word of the day",
|
|
"schedule": {"kind": "once", "run_at": run_at, "display": "once at 2026-03-18 04:22"},
|
|
"schedule_display": "once at 2026-03-18 04:22",
|
|
"repeat": {"times": 1, "completed": 0},
|
|
"enabled": True,
|
|
"state": "scheduled",
|
|
"paused_at": None,
|
|
"paused_reason": None,
|
|
"created_at": "2026-03-18T04:21:00+00:00",
|
|
"next_run_at": None,
|
|
"last_run_at": None,
|
|
"last_status": None,
|
|
"last_error": None,
|
|
"deliver": "local",
|
|
"origin": None,
|
|
}]
|
|
)
|
|
|
|
due = get_due_jobs()
|
|
|
|
assert [job["id"] for job in due] == ["oneshot-recover"]
|
|
# Recovery restores next_run_at to the original run time; the
|
|
# cross-process double-exec guard (#59229) is a separate run_claim
|
|
# stamped under the lock, not a next_run_at mutation.
|
|
recovered = get_job("oneshot-recover")
|
|
assert recovered["next_run_at"] == run_at
|
|
assert recovered.get("run_claim") is not None
|
|
assert recovered["run_claim"]["at"] == now.isoformat()
|
|
|
|
|
|
def test_one_shot_not_redispatched_while_running(self, tmp_cron_dir, monkeypatch):
|
|
"""#59229: two concurrent schedulers must not double-execute a one-shot.
|
|
|
|
Reproduces the reported failure with a job whose run OUTLIVES the tick
|
|
interval (a ~2.5-min research prompt). Process A's tick returns it as
|
|
due and stamps a run_claim; while A is still running, every later tick
|
|
(process B, or A's own next tick) must see the fresh claim and skip —
|
|
not just for one tick window but for the whole run.
|
|
"""
|
|
from cron.jobs import _hermes_now
|
|
t0 = _hermes_now()
|
|
run_at = (t0 - timedelta(seconds=5)).isoformat()
|
|
save_jobs([{
|
|
"id": "long-oneshot", "name": "R", "prompt": "2.5min research",
|
|
"schedule": {"kind": "once", "run_at": run_at},
|
|
"next_run_at": run_at, "enabled": True, "state": "scheduled",
|
|
}])
|
|
|
|
# Process A tick: picks it up + claims it.
|
|
dueA = get_due_jobs()
|
|
assert [j["id"] for j in dueA] == ["long-oneshot"]
|
|
assert get_job("long-oneshot").get("run_claim") is not None
|
|
|
|
# Process B (and A's own subsequent ticks) while A is still running:
|
|
# 28s later (the exact gap in the report) AND 61s later (past any
|
|
# fixed +60s window) — both must skip.
|
|
for gap in (28, 61, 130):
|
|
monkeypatch.setattr("cron.jobs._hermes_now",
|
|
lambda t0=t0, g=gap: t0 + timedelta(seconds=g))
|
|
assert get_due_jobs() == [], f"double-dispatched at +{gap}s"
|
|
|
|
|
|
def test_run_claim_heartbeat_keeps_long_run_claimed_past_ttl(
|
|
self, tmp_cron_dir, monkeypatch
|
|
):
|
|
"""#62002 cross-process leg: a heartbeat-refreshed claim never expires
|
|
while the run is alive, so no other tick re-dispatches or stale-removes
|
|
the job even when the run outlives the original TTL horizon."""
|
|
monkeypatch.delenv("HERMES_CRON_TIMEOUT", raising=False)
|
|
from cron.jobs import _hermes_now, _oneshot_run_claim_ttl_seconds
|
|
ttl = _oneshot_run_claim_ttl_seconds()
|
|
t0 = _hermes_now()
|
|
run_at = (t0 - timedelta(seconds=5)).isoformat()
|
|
save_jobs([{
|
|
"id": "slowrun", "name": "R", "prompt": "x",
|
|
"schedule": {"kind": "once", "run_at": run_at},
|
|
"next_run_at": run_at, "enabled": True, "state": "scheduled",
|
|
"repeat": {"times": 1, "completed": 0},
|
|
}])
|
|
|
|
# Tick claims + dispatches the job.
|
|
assert [j["id"] for j in get_due_jobs()] == ["slowrun"]
|
|
assert claim_dispatch("slowrun") is True
|
|
|
|
# Mid-run heartbeat before the TTL horizon refreshes the claim.
|
|
monkeypatch.setattr("cron.jobs._hermes_now",
|
|
lambda: t0 + timedelta(seconds=ttl - 60))
|
|
owner = get_job("slowrun")["run_claim"]["by"]
|
|
assert heartbeat_run_claim("slowrun", expected_owner=owner) is True
|
|
|
|
# Past the ORIGINAL claim's TTL horizon: without the heartbeat this
|
|
# tick would stale-remove the maxed one-shot; with it the claim is
|
|
# fresh, so the job is skipped and the record survives.
|
|
monkeypatch.setattr("cron.jobs._hermes_now",
|
|
lambda: t0 + timedelta(seconds=ttl + 10))
|
|
assert get_due_jobs() == []
|
|
assert get_job("slowrun") is not None
|
|
|
|
# Run completes → outcome lands on a record that still exists
|
|
# (times=1 reached, so mark_job_run retires the job normally).
|
|
mark_job_run("slowrun", True)
|
|
assert get_job("slowrun") is None
|
|
|
|
|
|
def test_heartbeat_run_claim_rejects_replaced_owner(self, tmp_cron_dir):
|
|
"""A resumed stale runner must not keep a newer owner's claim alive."""
|
|
future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
|
|
original_at = datetime.now(timezone.utc).isoformat()
|
|
save_jobs([{
|
|
"id": "reclaimed", "name": "R", "prompt": "x",
|
|
"schedule": {"kind": "once", "run_at": future},
|
|
"next_run_at": future, "enabled": True, "state": "scheduled",
|
|
"run_claim": {"at": original_at, "by": "new-owner"},
|
|
}])
|
|
|
|
assert heartbeat_run_claim("reclaimed", expected_owner="old-owner") is False
|
|
assert get_job("reclaimed")["run_claim"] == {
|
|
"at": original_at,
|
|
"by": "new-owner",
|
|
}
|
|
|
|
|
|
class TestEnabledToolsets:
|
|
def test_enabled_toolsets_stored(self, tmp_cron_dir):
|
|
job = create_job(prompt="monitor", schedule="every 1h", enabled_toolsets=["web", "terminal"])
|
|
assert job["enabled_toolsets"] == ["web", "terminal"]
|
|
|
|
|
|
class TestMarkJobRunConcurrency:
|
|
"""Regression tests for concurrent parallel job state writes.
|
|
|
|
tick() dispatches multiple jobs to separate threads simultaneously.
|
|
Without _jobs_file_lock protecting the load→modify→save cycle in
|
|
mark_job_run(), concurrent writes can clobber each other's updates
|
|
(last-writer-wins), leaving some jobs with stale last_status / last_run_at.
|
|
"""
|
|
|
|
def test_three_concurrent_mark_job_run_no_overwrites(self, tmp_cron_dir):
|
|
"""Run mark_job_run() for 3 jobs in parallel threads; all must land correctly."""
|
|
# Create 3 distinct recurring jobs
|
|
job_a = create_job(prompt="Job A", schedule="every 1h")
|
|
job_b = create_job(prompt="Job B", schedule="every 1h")
|
|
job_c = create_job(prompt="Job C", schedule="every 1h")
|
|
|
|
errors: list = []
|
|
|
|
def run_mark(job_id: str, success: bool, error_msg=None):
|
|
try:
|
|
mark_job_run(job_id, success=success, error=error_msg)
|
|
except Exception as exc: # pragma: no cover
|
|
errors.append(exc)
|
|
|
|
# Fire all three concurrently
|
|
threads = [
|
|
threading.Thread(target=run_mark, args=(job_a["id"], True)),
|
|
threading.Thread(target=run_mark, args=(job_b["id"], False, "timeout")),
|
|
threading.Thread(target=run_mark, args=(job_c["id"], True)),
|
|
]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
assert not errors, f"Unexpected exceptions in worker threads: {errors}"
|
|
|
|
# Verify each job has the correct state — no overwrites
|
|
a = get_job(job_a["id"])
|
|
b = get_job(job_b["id"])
|
|
c = get_job(job_c["id"])
|
|
|
|
assert a is not None, "Job A was unexpectedly deleted"
|
|
assert b is not None, "Job B was unexpectedly deleted"
|
|
assert c is not None, "Job C was unexpectedly deleted"
|
|
|
|
assert a["last_status"] == "ok", f"Job A last_status wrong: {a['last_status']}"
|
|
assert a["last_run_at"] is not None, "Job A last_run_at not set"
|
|
assert a["repeat"]["completed"] == 1, f"Job A completed count wrong: {a['repeat']['completed']}"
|
|
|
|
assert b["last_status"] == "error", f"Job B last_status wrong: {b['last_status']}"
|
|
assert b["last_error"] == "timeout", f"Job B last_error wrong: {b['last_error']}"
|
|
assert b["last_run_at"] is not None, "Job B last_run_at not set"
|
|
assert b["repeat"]["completed"] == 1, f"Job B completed count wrong: {b['repeat']['completed']}"
|
|
|
|
assert c["last_status"] == "ok", f"Job C last_status wrong: {c['last_status']}"
|
|
assert c["last_run_at"] is not None, "Job C last_run_at not set"
|
|
assert c["repeat"]["completed"] == 1, f"Job C completed count wrong: {c['repeat']['completed']}"
|
|
|
|
|
|
class TestBadNextRunAtRecovery:
|
|
"""Regression: malformed next_run_at must not crash the due scan or starve siblings.
|
|
|
|
Mirrors the id-less and non-dict-schedule patterns: a single bad persisted
|
|
record in jobs.json must not abort _get_due_jobs_locked before save.
|
|
"""
|
|
|
|
def test_bad_next_run_at_does_not_crash_or_block_sibling_jobs(self, tmp_cron_dir):
|
|
"""One job with unparseable next_run_at + one healthy due sibling.
|
|
|
|
get_due_jobs must succeed and return the healthy job; the bad record
|
|
must be repaired (next_run_at cleared so recovery can set a sane value).
|
|
"""
|
|
from datetime import timezone, timedelta as td
|
|
now = datetime.now(timezone.utc)
|
|
past = (now - td(seconds=30)).isoformat()
|
|
future = (now + td(days=1)).isoformat()
|
|
|
|
# Bad record: next_run_at is not a valid ISO string (e.g. from hand-edit or corruption)
|
|
# Healthy sibling is past due with good schedule.
|
|
bad_job = {
|
|
"id": "bad-next",
|
|
"schedule": {"kind": "interval", "minutes": 60},
|
|
"next_run_at": "not-a-valid-iso-timestamp!!!",
|
|
"enabled": True,
|
|
"created_at": past,
|
|
}
|
|
good_job = {
|
|
"id": "good-sibling",
|
|
"schedule": {"kind": "interval", "minutes": 5},
|
|
"next_run_at": past,
|
|
"enabled": True,
|
|
"created_at": past,
|
|
}
|
|
save_jobs([bad_job, good_job])
|
|
|
|
# Must not raise
|
|
due = get_due_jobs()
|
|
|
|
# The healthy job must still be returned
|
|
ids = [j["id"] for j in due]
|
|
assert "good-sibling" in ids, f"healthy sibling missing from due jobs: {ids}"
|
|
assert "bad-next" not in ids # bad one may be repaired and/or not yet due after repair
|
|
|
|
# Bad job should have been auto-repaired (next_run_at stripped or fixed)
|
|
repaired = get_job("bad-next")
|
|
assert repaired is not None
|
|
nr = repaired.get("next_run_at")
|
|
if nr is not None:
|
|
# If still present it must now be parseable
|
|
datetime.fromisoformat(nr)
|
|
|
|
# Calling again must remain stable (no crash on re-scan)
|
|
due2 = get_due_jobs()
|
|
assert any(j["id"] == "good-sibling" for j in due2)
|
|
|
|
|
|
class TestPerJobScanContainment:
|
|
"""Structural guard: ANY per-job exception in the due scan must degrade to
|
|
skipping that one job for the tick — never abort the scan and starve
|
|
healthy siblings (the freeze class behind bad id / schedule / next_run_at).
|
|
"""
|
|
|
|
def test_unforeseen_per_job_exception_does_not_starve_siblings(self, tmp_cron_dir):
|
|
"""Simulate a FUTURE malformed-field variant none of the shape
|
|
normalizers repair, by making grace computation raise for one job
|
|
only. The per-job guard must skip it and still return the sibling."""
|
|
from datetime import timezone, timedelta as td
|
|
from unittest.mock import patch as mock_patch
|
|
|
|
now = datetime.now(timezone.utc)
|
|
past = (now - td(seconds=30)).isoformat()
|
|
|
|
poison = {
|
|
"id": "poison",
|
|
# minutes=7 tags this schedule so the patched helper can target it
|
|
"schedule": {"kind": "interval", "minutes": 7},
|
|
"next_run_at": past,
|
|
"enabled": True,
|
|
"created_at": past,
|
|
}
|
|
good = {
|
|
"id": "good-sibling",
|
|
"schedule": {"kind": "interval", "minutes": 5},
|
|
"next_run_at": past,
|
|
"enabled": True,
|
|
"created_at": past,
|
|
}
|
|
save_jobs([poison, good])
|
|
|
|
import cron.jobs as jobs_mod
|
|
real_grace = jobs_mod._compute_grace_seconds
|
|
|
|
def selective_grace(schedule):
|
|
if schedule.get("minutes") == 7:
|
|
raise RuntimeError("simulated unforeseen malformed field")
|
|
return real_grace(schedule)
|
|
|
|
with mock_patch.object(jobs_mod, "_compute_grace_seconds", selective_grace):
|
|
due = get_due_jobs() # must not raise
|
|
|
|
ids = [j["id"] for j in due]
|
|
assert "good-sibling" in ids, f"healthy sibling starved: {ids}"
|
|
assert "poison" not in ids
|
|
|
|
# Scheduler stays alive on subsequent ticks too.
|
|
with mock_patch.object(jobs_mod, "_compute_grace_seconds", selective_grace):
|
|
due2 = get_due_jobs()
|
|
assert any(j["id"] == "good-sibling" for j in due2)
|
|
|
|
|
|
class TestSaveJobOutput:
|
|
def test_creates_output_file(self, tmp_cron_dir):
|
|
output_file = save_job_output("test123", "# Results\nEverything ok.")
|
|
assert output_file.exists()
|
|
assert output_file.read_text() == "# Results\nEverything ok."
|
|
assert "test123" in str(output_file)
|
|
|
|
|
|
class TestCronOutputRetention:
|
|
"""Per-run cron output must self-prune so long deploys don't fill the disk (#52383)."""
|
|
|
|
@staticmethod
|
|
def _seed(d, count):
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
names = [f"2026-06-25_10-00-{i:02d}.md" for i in range(count)]
|
|
for n in names:
|
|
(d / n).write_text("x")
|
|
return names
|
|
|
|
def test_prune_keeps_newest_n(self, tmp_path):
|
|
from cron.jobs import _prune_job_output
|
|
d = tmp_path / "job"
|
|
names = self._seed(d, 10)
|
|
assert _prune_job_output(d, keep=3) == 7
|
|
assert sorted(p.name for p in d.glob("*.md")) == names[-3:]
|
|
|
|
|
|
# =========================================================================
|
|
# claim_dispatch — pre-run one-shot crash safety (issue #38758)
|
|
# =========================================================================
|
|
|
|
class TestClaimDispatch:
|
|
"""One-shot jobs must commit their dispatch BEFORE the side effect runs, so
|
|
a tick that dies mid-execution (gateway kill, OOM, hard-timeout) can re-fire
|
|
the job at most ``repeat.times`` times instead of infinitely."""
|
|
|
|
def _oneshot(self, times=1, completed=0):
|
|
return {
|
|
"id": "os1",
|
|
"name": "one-shot",
|
|
"enabled": True,
|
|
"schedule": {"kind": "once", "run_at": "2026-01-01T00:00:00+00:00"},
|
|
"repeat": {"times": times, "completed": completed},
|
|
}
|
|
|
|
def test_claim_increments_and_persists(self, tmp_cron_dir):
|
|
save_jobs([self._oneshot(times=1, completed=0)])
|
|
assert claim_dispatch("os1") is True
|
|
# Persisted BEFORE any side effect — survives a crash.
|
|
assert load_jobs()[0]["repeat"]["completed"] == 1
|
|
|
|
def test_already_dispatched_oneshot_is_removed(self, tmp_cron_dir):
|
|
# A prior tick claimed (completed==times) then died before mark_job_run
|
|
# could remove the job. The next claim must refuse AND clean up.
|
|
save_jobs([self._oneshot(times=1, completed=1)])
|
|
assert claim_dispatch("os1") is False
|
|
assert load_jobs() == [] # removed, will not re-fire
|
|
|
|
|
|
def test_mark_job_run_does_not_double_count_preclaimed_oneshot(self, tmp_cron_dir):
|
|
# Full lifecycle: claim bumps completed to times, then mark_job_run must
|
|
# NOT increment again — it recognizes the pre-claim and removes the job.
|
|
save_jobs([self._oneshot(times=1, completed=0)])
|
|
assert claim_dispatch("os1") is True
|
|
assert load_jobs()[0]["repeat"]["completed"] == 1
|
|
mark_job_run("os1", success=True)
|
|
assert load_jobs() == [] # completed once, removed — not fired twice
|
|
|
|
|
|
def test_get_due_jobs_removes_stale_maxed_oneshot(self, tmp_cron_dir):
|
|
# A claimed one-shot whose tick died leaves completed>=times with
|
|
# last_run_at still unset, so the recovery helper re-arms it as due.
|
|
# get_due_jobs must drop it instead of returning it for another fire.
|
|
past = (datetime.now(timezone.utc) - timedelta(seconds=5)).isoformat()
|
|
save_jobs([{
|
|
"id": "os1",
|
|
"name": "one-shot",
|
|
"enabled": True,
|
|
"schedule": {"kind": "once", "run_at": past},
|
|
"repeat": {"times": 1, "completed": 1},
|
|
"next_run_at": None,
|
|
}])
|
|
due = get_due_jobs()
|
|
assert due == []
|
|
assert load_jobs() == [] # cleaned up
|
|
|
|
|
|
class TestLateEnvRepointScopesStore:
|
|
"""A HERMES_HOME set AFTER cron.jobs import must scope the store even
|
|
without use_cron_store(): fixtures that patch the environment too late
|
|
previously read/wrote the import-time jobs.json — the user's real file."""
|
|
|
|
def test_late_env_repoint_scopes_store(self, tmp_path, monkeypatch):
|
|
import cron.jobs as jobs
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
store = jobs._current_cron_store()
|
|
expected = tmp_path.resolve() / "cron"
|
|
assert store.cron_dir == expected
|
|
assert store.jobs_file == expected / "jobs.json"
|
|
assert store.output_dir == expected / "output"
|
|
# the import-time compatibility constants are untouched
|
|
assert jobs.JOBS_FILE != store.jobs_file
|
|
|
|
|
|
def test_use_cron_store_override_still_wins(self, tmp_path, monkeypatch):
|
|
import cron.jobs as jobs
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "env-home"))
|
|
with jobs.use_cron_store(tmp_path / "override-home"):
|
|
store = jobs._current_cron_store()
|
|
assert store.jobs_file == (tmp_path / "override-home").resolve() / "cron" / "jobs.json"
|
|
|
|
|
|
def test_public_io_after_late_env_repoint_leaves_old_file_untouched(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""The public API, not the store internals: save_jobs()/load_jobs()
|
|
called after a post-import HERMES_HOME repoint must operate on the NEW
|
|
home's jobs.json and leave the import-time file byte-identical.
|
|
|
|
The "import-time home" is SIMULATED at a tmp location by patching the
|
|
module constants and the import-time snapshot together (so they still
|
|
compare equal and the deliberate-repoint branch does not fire). The
|
|
test must never touch the real import-time jobs.json: if this module
|
|
was first imported before the suite's env isolation applied, that
|
|
path IS the developer's live file — writing a sentinel there is
|
|
exactly the incident this PR exists to prevent."""
|
|
import cron.jobs as jobs
|
|
|
|
sim_old_home = tmp_path / "import-time-home"
|
|
sim_cron = sim_old_home / "cron"
|
|
monkeypatch.setattr(jobs, "HERMES_DIR", sim_old_home)
|
|
monkeypatch.setattr(jobs, "CRON_DIR", sim_cron)
|
|
monkeypatch.setattr(jobs, "JOBS_FILE", sim_cron / "jobs.json")
|
|
monkeypatch.setattr(jobs, "OUTPUT_DIR", sim_cron / "output")
|
|
monkeypatch.setattr(
|
|
jobs, "_IMPORT_STORE",
|
|
jobs._CronStorePaths(jobs.CRON_DIR, jobs.JOBS_FILE, jobs.OUTPUT_DIR),
|
|
)
|
|
|
|
# Plant a sentinel at the (simulated) import-time location — the file
|
|
# a late-patching fixture used to clobber.
|
|
old_file = jobs.JOBS_FILE
|
|
old_file.parent.mkdir(parents=True, exist_ok=True)
|
|
sentinel = '[{"id": "sentinel-do-not-touch"}]'
|
|
old_file.write_text(sentinel, encoding="utf-8")
|
|
|
|
new_home = tmp_path / "late-home"
|
|
monkeypatch.setenv("HERMES_HOME", str(new_home))
|
|
|
|
job = {
|
|
"id": "lateenvjob01",
|
|
"name": "late-env",
|
|
"prompt": None,
|
|
"schedule_display": None,
|
|
"schedule": {"kind": "interval", "minutes": 60, "display": "every 60m"},
|
|
"enabled": True,
|
|
}
|
|
save_jobs([job])
|
|
|
|
# public read round-trips from the NEW home...
|
|
loaded = load_jobs()
|
|
assert [j["id"] for j in loaded] == ["lateenvjob01"]
|
|
new_file = new_home.resolve() / "cron" / "jobs.json"
|
|
assert new_file.is_file()
|
|
# ...and the import-time file is byte-identical to the sentinel.
|
|
assert old_file.read_text(encoding="utf-8") == sentinel
|
|
|
|
|
|
# =========================================================================
|
|
# UTF-8 BOM on jobs.json (Windows Notepad / PowerShell 5.1)
|
|
# =========================================================================
|
|
|
|
class TestJobsJsonUtf8Bom:
|
|
"""jobs.json readers must accept a leading UTF-8 BOM.
|
|
|
|
Matching the env-class dialect (utf-8-sig): a BOM from Windows editors
|
|
must not raise JSONDecodeError / RuntimeError on load_jobs().
|
|
"""
|
|
|
|
def test_load_jobs_accepts_utf8_bom(self, tmp_cron_dir):
|
|
"""BOM'd jobs.json loads — the pre-fix crash repro."""
|
|
import json
|
|
from pathlib import Path
|
|
from cron.jobs import JOBS_FILE, load_jobs
|
|
|
|
payload = {
|
|
"jobs": [
|
|
{
|
|
"id": "bomjob01",
|
|
"name": "bom-test",
|
|
"enabled": True,
|
|
"prompt": "hello",
|
|
"schedule": {"kind": "interval", "minutes": 60, "display": "every 60m"},
|
|
}
|
|
]
|
|
}
|
|
JOBS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
JOBS_FILE.write_bytes(
|
|
b"\xef\xbb\xbf" + json.dumps(payload).encode("utf-8")
|
|
)
|
|
|
|
loaded = load_jobs()
|
|
assert [j["id"] for j in loaded] == ["bomjob01"]
|
|
assert loaded[0]["name"] == "bom-test"
|
|
|
|
def test_load_jobs_bomless_regression(self, tmp_cron_dir):
|
|
"""BOM-less UTF-8 jobs.json must keep loading after utf-8-sig."""
|
|
import json
|
|
from cron.jobs import JOBS_FILE, load_jobs
|
|
|
|
payload = {
|
|
"jobs": [
|
|
{
|
|
"id": "plainjob01",
|
|
"name": "plain",
|
|
"enabled": True,
|
|
"prompt": "hi",
|
|
"schedule": {"kind": "interval", "minutes": 30, "display": "every 30m"},
|
|
}
|
|
]
|
|
}
|
|
JOBS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
JOBS_FILE.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
loaded = load_jobs()
|
|
assert [j["id"] for j in loaded] == ["plainjob01"]
|
|
|
|
|