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).
169 lines
5.7 KiB
Python
169 lines
5.7 KiB
Python
"""Regression tests for #60703 — cron ticker silently stalls after gateway restart.
|
|
|
|
Three fixes under test:
|
|
|
|
1. ``_jobs_lock()`` bounds its cross-process flock: when another process holds
|
|
``.jobs.lock`` indefinitely, acquisition times out, logs at ERROR, and falls
|
|
through to in-process-only locking — instead of blocking the calling thread
|
|
(and, transitively, the cron ticker heartbeat) forever.
|
|
|
|
2. Claim freshness checks are bounded on both sides (``0 <= age < ttl``): a
|
|
``fire_claim``/``run_claim`` stamped in the FUTURE (clock/TZ skew across a
|
|
restart) is treated as stale/overwritable, not eternally fresh.
|
|
|
|
3. ``_execute_job_now`` no longer mislabels paused/disabled/missing jobs as
|
|
"already being fired".
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import threading
|
|
import time
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import cron.jobs as jobs_mod
|
|
from cron.jobs import (
|
|
_jobs_lock,
|
|
claim_job_for_fire,
|
|
create_job,
|
|
get_due_jobs,
|
|
get_job,
|
|
load_jobs,
|
|
save_jobs,
|
|
)
|
|
|
|
|
|
try:
|
|
import fcntl
|
|
except ImportError: # pragma: no cover - non-POSIX
|
|
fcntl = None
|
|
|
|
|
|
pytestmark = pytest.mark.skipif(fcntl is None, reason="flock semantics are POSIX-only")
|
|
|
|
|
|
def _hold_jobs_flock(path: Path, release: threading.Event, held: threading.Event):
|
|
"""Hold an exclusive flock on *path* from a separate fd until released.
|
|
|
|
flock locks are per-open-file-description, so a second open() in the SAME
|
|
process contends exactly like another process would.
|
|
"""
|
|
fd = open(path, "a+", encoding="utf-8")
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
held.set()
|
|
release.wait(timeout=30)
|
|
finally:
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
except OSError:
|
|
pass
|
|
fd.close()
|
|
|
|
|
|
class TestBoundedJobsLock:
|
|
def test_lock_acquisition_times_out_and_degrades(self, monkeypatch, caplog):
|
|
"""A foreign holder of .jobs.lock must NOT block _jobs_lock forever."""
|
|
jobs_mod.ensure_dirs()
|
|
lock_path = jobs_mod._jobs_lock_file()
|
|
lock_path.touch()
|
|
|
|
monkeypatch.setattr(jobs_mod, "_JOBS_LOCK_TIMEOUT_SECONDS", 1.0)
|
|
|
|
release = threading.Event()
|
|
held = threading.Event()
|
|
holder = threading.Thread(
|
|
target=_hold_jobs_flock, args=(lock_path, release, held), daemon=True
|
|
)
|
|
holder.start()
|
|
assert held.wait(timeout=10), "test holder failed to take the flock"
|
|
|
|
try:
|
|
start = time.monotonic()
|
|
entered = False
|
|
with caplog.at_level("ERROR", logger="cron.jobs"):
|
|
with _jobs_lock():
|
|
entered = True
|
|
elapsed = time.monotonic() - start
|
|
|
|
assert entered, "critical section must still run in degraded mode"
|
|
assert elapsed < 10, f"lock wait was not bounded (took {elapsed:.1f}s)"
|
|
assert any("Timed out" in r.message for r in caplog.records), (
|
|
"degraded-mode fallback must be logged at ERROR"
|
|
)
|
|
finally:
|
|
release.set()
|
|
holder.join(timeout=10)
|
|
|
|
def test_uncontended_lock_is_fast_and_silent(self, caplog):
|
|
jobs_mod.ensure_dirs()
|
|
start = time.monotonic()
|
|
with caplog.at_level("ERROR", logger="cron.jobs"):
|
|
with _jobs_lock():
|
|
pass
|
|
assert time.monotonic() - start < 5
|
|
assert not [r for r in caplog.records if "Timed out" in r.message]
|
|
|
|
def test_reentrant_nesting_still_works(self):
|
|
with _jobs_lock():
|
|
with _jobs_lock(): # must not deadlock or re-flock
|
|
pass
|
|
|
|
|
|
class TestFutureDatedClaims:
|
|
def _make_job(self, **kw):
|
|
return create_job(name="claim job", schedule="0 7 * * *", prompt="x", **kw)
|
|
|
|
def test_future_fire_claim_is_treated_as_stale(self):
|
|
"""A fire_claim stamped in the future must not block claiming forever."""
|
|
job = self._make_job()
|
|
jobs = load_jobs()
|
|
for j in jobs:
|
|
if j["id"] == job["id"]:
|
|
future = jobs_mod._hermes_now() + timedelta(hours=6)
|
|
j["fire_claim"] = {"at": future.isoformat(), "by": "other-host:1"}
|
|
save_jobs(jobs)
|
|
|
|
assert claim_job_for_fire(job["id"]) is True, (
|
|
"future-dated claim must be overwritable, not eternally fresh"
|
|
)
|
|
|
|
def test_fresh_past_fire_claim_still_blocks(self):
|
|
job = self._make_job()
|
|
assert claim_job_for_fire(job["id"]) is True
|
|
# Immediately re-claiming must be refused — claim is genuinely fresh.
|
|
assert claim_job_for_fire(job["id"]) is False
|
|
|
|
def test_expired_fire_claim_is_reclaimable(self):
|
|
job = self._make_job()
|
|
jobs = load_jobs()
|
|
for j in jobs:
|
|
if j["id"] == job["id"]:
|
|
past = jobs_mod._hermes_now() - timedelta(hours=6)
|
|
j["fire_claim"] = {"at": past.isoformat(), "by": "other-host:1"}
|
|
save_jobs(jobs)
|
|
assert claim_job_for_fire(job["id"]) is True
|
|
|
|
|
|
class TestHonestRunSkipMessages:
|
|
def test_paused_job_not_reported_as_already_firing(self):
|
|
from tools.cronjob_tools import _execute_job_now
|
|
|
|
job = create_job(name="paused job", schedule="0 7 * * *", prompt="x")
|
|
from cron.jobs import pause_job
|
|
|
|
pause_job(job["id"])
|
|
res = _execute_job_now(get_job(job["id"]))
|
|
assert res["claimed"] is False
|
|
assert "paused" in (res["error"] or "").lower()
|
|
assert "already being fired" not in (res["error"] or "").lower()
|
|
|
|
def test_missing_job_not_reported_as_already_firing(self):
|
|
from tools.cronjob_tools import _execute_job_now
|
|
|
|
res = _execute_job_now({"id": "does-not-exist-123"})
|
|
assert res["claimed"] is False
|
|
assert "no longer exists" in (res["error"] or "").lower()
|