mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 17:27:37 +08:00
When the curator consolidates skill X into umbrella Y, any cron job that listed X in its skills field would fail to load X at run time — the scheduler logs a warning and skips it, so the scheduled job runs without the instructions it was scheduled to follow. cron.jobs.rewrite_skill_refs(consolidated, pruned) now updates jobs in-place: consolidated names route to the umbrella target (dedup when umbrella is already present), pruned names are dropped. agent.curator._write_run_report calls it after classification, best-effort so a cron-side failure never breaks the curator itself. Results are recorded in run.json (counts.cron_jobs_rewritten + full cron_rewrites payload), a separate cron_rewrites.json for convenience when jobs were touched, and a section in REPORT.md. Reported by @tombielecki.
290 lines
10 KiB
Python
290 lines
10 KiB
Python
"""Tests for cron.jobs.rewrite_skill_refs — the curator integration that
|
|
keeps scheduled cron jobs pointing at the right skill names after a
|
|
consolidation / pruning pass.
|
|
|
|
Bug this fixes: when the curator consolidates skill X into umbrella Y,
|
|
any cron job whose ``skills`` list contains X would silently fail to
|
|
load X at run time (the scheduler logs a warning and skips it), so the
|
|
job runs without the instructions it was scheduled to follow.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
# Ensure project root is importable
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
|
|
|
|
@pytest.fixture
|
|
def cron_env(tmp_path, monkeypatch):
|
|
"""Isolated cron environment with temp HERMES_HOME."""
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
(hermes_home / "cron").mkdir()
|
|
(hermes_home / "cron" / "output").mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
import cron.jobs as jobs_mod
|
|
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
|
|
|
|
|
|
class TestRewriteSkillRefsNoop:
|
|
"""No jobs, no rewrites, no map — every combination of empty inputs."""
|
|
|
|
def test_empty_map_and_no_jobs(self, cron_env):
|
|
from cron.jobs import rewrite_skill_refs
|
|
|
|
report = rewrite_skill_refs(consolidated={}, pruned=[])
|
|
assert report == {"rewrites": [], "jobs_updated": 0, "jobs_scanned": 0}
|
|
|
|
def test_jobs_exist_but_map_empty(self, cron_env):
|
|
from cron.jobs import create_job, rewrite_skill_refs
|
|
|
|
create_job(prompt="", schedule="every 1h", skills=["foo"])
|
|
report = rewrite_skill_refs(consolidated={}, pruned=[])
|
|
assert report["jobs_updated"] == 0
|
|
# Early return: we don't even scan when there's nothing to apply.
|
|
assert report["jobs_scanned"] == 0
|
|
|
|
def test_jobs_exist_but_no_match(self, cron_env):
|
|
from cron.jobs import create_job, get_job, rewrite_skill_refs
|
|
|
|
job = create_job(prompt="", schedule="every 1h", skills=["foo"])
|
|
report = rewrite_skill_refs(
|
|
consolidated={"unrelated": "umbrella"},
|
|
pruned=["other"],
|
|
)
|
|
assert report["jobs_updated"] == 0
|
|
assert report["jobs_scanned"] == 1
|
|
# Job untouched
|
|
loaded = get_job(job["id"])
|
|
assert loaded["skills"] == ["foo"]
|
|
|
|
|
|
class TestRewriteSkillRefsConsolidation:
|
|
"""Consolidated skills should be replaced with their umbrella target."""
|
|
|
|
def test_single_skill_replaced(self, cron_env):
|
|
from cron.jobs import create_job, get_job, rewrite_skill_refs
|
|
|
|
job = create_job(prompt="", schedule="every 1h", skills=["legacy-skill"])
|
|
report = rewrite_skill_refs(
|
|
consolidated={"legacy-skill": "umbrella-skill"},
|
|
pruned=[],
|
|
)
|
|
|
|
assert report["jobs_updated"] == 1
|
|
loaded = get_job(job["id"])
|
|
assert loaded["skills"] == ["umbrella-skill"]
|
|
# Legacy ``skill`` field realigned
|
|
assert loaded["skill"] == "umbrella-skill"
|
|
|
|
def test_multiple_skills_one_consolidated(self, cron_env):
|
|
from cron.jobs import create_job, get_job, rewrite_skill_refs
|
|
|
|
job = create_job(
|
|
prompt="",
|
|
schedule="every 1h",
|
|
skills=["keep-a", "legacy", "keep-b"],
|
|
)
|
|
rewrite_skill_refs(consolidated={"legacy": "umbrella"}, pruned=[])
|
|
|
|
loaded = get_job(job["id"])
|
|
# Ordering preserved, legacy replaced in-place
|
|
assert loaded["skills"] == ["keep-a", "umbrella", "keep-b"]
|
|
|
|
def test_umbrella_already_in_list_dedupes(self, cron_env):
|
|
from cron.jobs import create_job, get_job, rewrite_skill_refs
|
|
|
|
# Job already loads the umbrella AND the legacy sub-skill
|
|
job = create_job(
|
|
prompt="",
|
|
schedule="every 1h",
|
|
skills=["umbrella", "legacy"],
|
|
)
|
|
rewrite_skill_refs(consolidated={"legacy": "umbrella"}, pruned=[])
|
|
|
|
loaded = get_job(job["id"])
|
|
# No duplicate — the umbrella stays exactly once
|
|
assert loaded["skills"] == ["umbrella"]
|
|
|
|
def test_rewrite_report_records_mapping(self, cron_env):
|
|
from cron.jobs import create_job, rewrite_skill_refs
|
|
|
|
job = create_job(
|
|
prompt="",
|
|
schedule="every 1h",
|
|
skills=["a", "b"],
|
|
name="my-job",
|
|
)
|
|
report = rewrite_skill_refs(
|
|
consolidated={"a": "umbrella-a", "b": "umbrella-b"},
|
|
pruned=[],
|
|
)
|
|
|
|
assert len(report["rewrites"]) == 1
|
|
entry = report["rewrites"][0]
|
|
assert entry["job_id"] == job["id"]
|
|
assert entry["job_name"] == "my-job"
|
|
assert entry["before"] == ["a", "b"]
|
|
assert entry["after"] == ["umbrella-a", "umbrella-b"]
|
|
assert entry["mapped"] == {"a": "umbrella-a", "b": "umbrella-b"}
|
|
assert entry["dropped"] == []
|
|
|
|
|
|
class TestRewriteSkillRefsPruning:
|
|
"""Pruned skills should be dropped outright (no forwarding target)."""
|
|
|
|
def test_pruned_skill_dropped(self, cron_env):
|
|
from cron.jobs import create_job, get_job, rewrite_skill_refs
|
|
|
|
job = create_job(
|
|
prompt="",
|
|
schedule="every 1h",
|
|
skills=["keep", "stale"],
|
|
)
|
|
report = rewrite_skill_refs(consolidated={}, pruned=["stale"])
|
|
|
|
assert report["jobs_updated"] == 1
|
|
loaded = get_job(job["id"])
|
|
assert loaded["skills"] == ["keep"]
|
|
assert loaded["skill"] == "keep"
|
|
|
|
def test_all_skills_pruned_leaves_empty_list(self, cron_env):
|
|
from cron.jobs import create_job, get_job, rewrite_skill_refs
|
|
|
|
job = create_job(prompt="", schedule="every 1h", skills=["gone"])
|
|
rewrite_skill_refs(consolidated={}, pruned=["gone"])
|
|
|
|
loaded = get_job(job["id"])
|
|
assert loaded["skills"] == []
|
|
assert loaded["skill"] is None
|
|
|
|
def test_pruned_report_records_drops(self, cron_env):
|
|
from cron.jobs import create_job, rewrite_skill_refs
|
|
|
|
create_job(prompt="", schedule="every 1h", skills=["keep", "stale"])
|
|
report = rewrite_skill_refs(consolidated={}, pruned=["stale"])
|
|
|
|
entry = report["rewrites"][0]
|
|
assert entry["dropped"] == ["stale"]
|
|
assert entry["mapped"] == {}
|
|
|
|
|
|
class TestRewriteSkillRefsMixed:
|
|
"""Consolidation + pruning in the same pass."""
|
|
|
|
def test_mixed_consolidation_and_pruning(self, cron_env):
|
|
from cron.jobs import create_job, get_job, rewrite_skill_refs
|
|
|
|
job = create_job(
|
|
prompt="",
|
|
schedule="every 1h",
|
|
skills=["keep", "legacy", "stale"],
|
|
)
|
|
rewrite_skill_refs(
|
|
consolidated={"legacy": "umbrella"},
|
|
pruned=["stale"],
|
|
)
|
|
|
|
loaded = get_job(job["id"])
|
|
assert loaded["skills"] == ["keep", "umbrella"]
|
|
|
|
def test_skill_in_both_maps_wins_as_consolidated(self, cron_env):
|
|
"""Defensive: if a skill appears in both lists (shouldn't happen
|
|
in practice), prefer consolidation — it has a forwarding target,
|
|
which is the more useful outcome."""
|
|
from cron.jobs import create_job, get_job, rewrite_skill_refs
|
|
|
|
job = create_job(prompt="", schedule="every 1h", skills=["ambiguous"])
|
|
rewrite_skill_refs(
|
|
consolidated={"ambiguous": "umbrella"},
|
|
pruned=["ambiguous"],
|
|
)
|
|
|
|
loaded = get_job(job["id"])
|
|
assert loaded["skills"] == ["umbrella"]
|
|
|
|
|
|
class TestRewriteSkillRefsMultipleJobs:
|
|
"""Multiple jobs, some affected, some not."""
|
|
|
|
def test_only_affected_jobs_reported(self, cron_env):
|
|
from cron.jobs import create_job, get_job, rewrite_skill_refs
|
|
|
|
j1 = create_job(prompt="", schedule="every 1h", skills=["legacy"])
|
|
j2 = create_job(prompt="", schedule="every 1h", skills=["untouched"])
|
|
j3 = create_job(prompt="", schedule="every 1h", skills=[])
|
|
|
|
report = rewrite_skill_refs(
|
|
consolidated={"legacy": "umbrella"},
|
|
pruned=[],
|
|
)
|
|
|
|
assert report["jobs_updated"] == 1
|
|
assert report["jobs_scanned"] == 3
|
|
assert len(report["rewrites"]) == 1
|
|
assert report["rewrites"][0]["job_id"] == j1["id"]
|
|
|
|
# Untouched jobs stay put
|
|
assert get_job(j2["id"])["skills"] == ["untouched"]
|
|
assert get_job(j3["id"])["skills"] == []
|
|
|
|
def test_legacy_skill_field_also_rewritten(self, cron_env):
|
|
"""Old jobs may have the legacy single-skill ``skill`` field
|
|
set instead of ``skills``. Both paths should be rewritten."""
|
|
from cron.jobs import create_job, get_job, rewrite_skill_refs
|
|
|
|
# Create via the legacy ``skill`` argument
|
|
job = create_job(
|
|
prompt="",
|
|
schedule="every 1h",
|
|
skill="legacy",
|
|
)
|
|
rewrite_skill_refs(consolidated={"legacy": "umbrella"}, pruned=[])
|
|
|
|
loaded = get_job(job["id"])
|
|
assert loaded["skills"] == ["umbrella"]
|
|
assert loaded["skill"] == "umbrella"
|
|
|
|
|
|
class TestRewriteSkillRefsPersistence:
|
|
"""Rewrites persist to disk and survive a reload."""
|
|
|
|
def test_changes_persist_across_reload(self, cron_env):
|
|
import json
|
|
from cron.jobs import create_job, rewrite_skill_refs, JOBS_FILE
|
|
|
|
create_job(prompt="", schedule="every 1h", skills=["legacy"])
|
|
rewrite_skill_refs(consolidated={"legacy": "umbrella"}, pruned=[])
|
|
|
|
# Read raw file contents
|
|
data = json.loads(JOBS_FILE.read_text())
|
|
assert data["jobs"][0]["skills"] == ["umbrella"]
|
|
assert data["jobs"][0]["skill"] == "umbrella"
|
|
|
|
def test_noop_does_not_rewrite_file(self, cron_env):
|
|
from cron.jobs import create_job, rewrite_skill_refs, JOBS_FILE
|
|
|
|
create_job(prompt="", schedule="every 1h", skills=["keep"])
|
|
mtime_before = JOBS_FILE.stat().st_mtime_ns
|
|
|
|
# Nothing in the map matches
|
|
report = rewrite_skill_refs(
|
|
consolidated={"unrelated": "umbrella"},
|
|
pruned=["other"],
|
|
)
|
|
|
|
assert report["jobs_updated"] == 0
|
|
# File untouched — no pointless disk write
|
|
assert JOBS_FILE.stat().st_mtime_ns == mtime_before
|