Cron and skills path handling across profiles
NousResearch/hermes-agent · cluster 20622 · cron
Data as of
6 open items. Fix merged in #50112. Check it covers your case.
The short answer
- Known?
- Yes. 30 related items grouped here, 8 issues already closed.
- Fix event
- A fix was merged to main in PR #50112. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #50112. That is separate from the fix event. - Still open
- 4 issues and 2 PRs.
Next step: read #50112, then check whether your report is one of the 4 still open.
Read #50112Pick up a Lab taskThese items concern how cron jobs and skill paths are stored and surfaced when multiple profiles or HERMES_HOME contexts are involved. There is ongoing, conflicting work around whether jobs should be isolated per profile, anchored to a root home, or aggregated in a shared dashboard view.
Open issues4
- Issue#32045[Bug] `hermes cron edit <id> --profile <name>` returns "Job not found" for existing jobs
- Issue#32046[Bug] `hermes cron create --profile <name>` switches HERMES_HOME instead of pinning — cron lands in target profile's jobs.json
- Issue#45335hermes cron edit --profile <name> returns "Job not found" for all jobs
- Issue#54650Cron scheduler ignores job profile — always runs as default identity
Open PRs2
Fixes on record
-
#50112 fix(cron): anchor cron storage at the default root home (#32091)
files:
cron/jobs.py,cron/scheduler.py,cron/suggestions.py,scripts/release.py,tests/cron/test_claim_job_for_fire.py,tests/cron/test_cron_profile_storage.pyShow the diff
-
cron/jobs.py@@ -31,7 +31,7 @@ msvcrt = None from datetime import datetime, timedelta from pathlib import Path -from hermes_constants import get_hermes_home +from hermes_constants import get_default_hermes_root, get_hermes_home from typing import Optional, Dict, List, Any, Union logger = logging.getLogger(__name__) @@ -49,7 +49,7 @@ # Configuration # ============================================================================= -HERMES_DIR = get_hermes_home().resolve() +HERMES_DIR = get_default_hermes_root().resolve() CRON_DIR = HERMES_DIR / "cron" JOBS_FILE = CRON_DIR / "jobs.json" # Heartbeat file the in-process ticker touches on every loop iteration. The @@ -615,10 +615,44 @@ def get_ticker_success_age() -> Optional[float]: # Job CRUD Operations # ============================================================================= +_WARNED_ORPHAN_STORE = False + + +def _warn_if_orphaned_profile_store() -> None: + """Loudly warn (once) if the root store is empty but a profile-local + jobs.json exists from before #32091's root-anchoring fix. + + Such a file is now unreachable (the store anchors at the default root, not + the active profile). The jobs in it were already orphaned pre-fix (the + profile-less gateway never read them), so this is not a regression — but a + user who could SEE them in `cron list` under their profile would otherwise + find them silently gone. Point them at the path instead of failing silent. + """ + global _WARNED_ORPHAN_STORE + if _WARNED_ORPHAN_STORE: + return + try: + active = get_hermes_home().resolve() + if active == HERMES_DIR: + return # not in a profile; nothing could be orphaned + legacy = active / "cron" / "jobs.json" + if legacy.exists(): + _WARNED_ORPHAN_STORE = True + logger.warning( + "Cron jobs now live at %s (shared across profiles). A legacy " + "profile-local store exists at %s and is no longer read; " + "re-create those jobs or move them into the root store. (#32091)", + JOBS_FILE, legacy, + ) + except Exception: + pass # best-effort advisory; never block load_jobs + + def load_jobs() -> List[Dict[str, Any]]: """Load all jobs from storage.""" ensure_dirs() if not JOBS_FILE.exists(): + _warn_if_orphaned_profile_store() return [] _strict_retry = False # track whether we used the strict=False fallback -
cron/scheduler.py@@ -283,9 +283,17 @@ def _get_hermes_home() -> Path: def _get_lock_paths() -> tuple[Path, Path]: - """Resolve cron lock paths at call time so profile/env changes are honored.""" - hermes_home = _get_hermes_home() - lock_dir = hermes_home / "cron" + """Resolve cron lock paths at call time so profile/env changes are honored. + + Anchored on the DEFAULT ROOT home (not the active profile), matching the + jobs store in cron.jobs (which uses get_default_hermes_root). The tick lock + is storage-coordination — it must live next to the single jobs.json so that + tickers running under different profiles share one lock and can't + double-fire the relocated store (#32091). Execution context (.env, + config.yaml, scripts) stays profile-aware via _get_hermes_home(). + """ + from hermes_constants import get_default_hermes_root + lock_dir = (_hermes_home or get_default_hermes_root()) / "cron" return lock_dir, lock_dir / ".tick.lock" -
cron/suggestions.py@@ -36,13 +36,13 @@ from pathlib import Path from typing import Any, Dict, List, Optional -from hermes_constants import get_hermes_home +from hermes_constants import get_default_hermes_root from hermes_time import now as _hermes_now from utils import atomic_replace logger = logging.getLogger(__name__) -CRON_DIR = get_hermes_home().resolve() / "cron" +CRON_DIR = get_default_hermes_root().resolve() / "cron" SUGGESTIONS_FILE = CRON_DIR / "suggestions.json" # In-process lock protecting load->modify->save cycles (the background review -
scripts/release.py@@ -1213,6 +1213,7 @@ "[redacted:email]": "holynn-q", "[redacted:email]": "jacdevos", "[redacted:email]": "liuhao1024", + "[redacted:email]": "mohamedorigami-jpg", # PR #32117 (cron storage root anchor; #32091) "[redacted:email]": "rbrtbn", "[redacted:email]": "VinciZhu", "[redacted:email]": "rodrigoeqnit", -
tests/cron/test_claim_job_for_fire.py@@ -14,7 +14,10 @@ def temp_home(tmp_path, monkeypatch): """Isolated HERMES_HOME so jobs.json doesn't touch the real store.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - # cron.jobs caches no home at import; get_hermes_home() reads the env live. + # NOTE: cron.jobs resolves its store paths (JOBS_FILE, CRON_DIR) from + # get_default_hermes_root() at IMPORT time, so setting HERMES_HOME here does + # not re-point an already-imported module's store. These tests exercise the + # claim logic on in-memory job dicts and don't depend on the on-disk path. yield tmp_path -
tests/cron/test_cron_profile_storage.py@@ -0,0 +1,105 @@ +"""Regression tests for #32091 — profile-scoped cron jobs orphaned. + +Cron storage (CRON_DIR/JOBS_FILE) must anchor at the *default root* Hermes +home, not the active profile's home. Otherwise a job created from a +profile-scoped agent session writes to ~/.hermes/profiles/<p>/cron/jobs.json, +while the profile-less gateway reads only ~/.hermes/cron/jobs.json — the job +is silently orphaned (looks healthy in `list`, never fires). +""" +import importlib +import os +from pathlib import Path + + +def test_cron_storage_anchors_at_root_under_profile(tmp_path, monkeypatch): + """Under a profile HERMES_HOME (<root>/profiles/<name>), the cron store + resolves to <root>/cron, NOT <root>/profiles/<name>/cron.""" + root = tmp_path / "hermes_home" + profile_home = root / "profiles" / "myprofile" + profile_home.mkdir(parents=True) + + # Pretend the platform default root IS our tmp root, and the active + # HERMES_HOME is a profile under it (the #32091 scenario). + import hermes_constants + monkeypatch.setattr(hermes_constants, "_get_platform_default_hermes_home", + lambda: root) + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + + # get_default_hermes_root must return the ROOT, not the profile dir. + assert hermes_constants.get_default_hermes_root().resolve() == root.resolve() + # ...while get_hermes_home (used elsewhere) follows the profile override. + assert hermes_constants.get_hermes_home().resolve() == profile_home.resolve() + + # cron/jobs.py computes HERMES_DIR from get_default_hermes_root at import, + # so a fresh import under this env anchors the store at <root>/cron. + import cron.jobs as jobs + importlib.reload(jobs) + try: + assert jobs.HERMES_DIR.resolve() == root.resolve() + assert jobs.JOBS_FILE.resolve() == (root / "cron" / "jobs.json").resolve() + # The orphan path (<profile>/cron/jobs.json) must NOT be the store. + assert jobs.JOBS_FILE.resolve() != (profile_home / "cron" / "jobs.json").resolve() + finally: + # Restore module state for other tests (reload under the real env). + monkeypatch.undo() + importlib.reload(jobs) + + +def test_cron_storage_unaffected_when_no_profile(tmp_path, monkeypatch): + """With no profile (HERMES_HOME == root), behavior is unchanged: store at + <root>/cron.""" + root = tmp_path / "hermes_home" + root.mkdir(parents=True) + import hermes_constants + monkeypatch.setattr(hermes_constants, "_get_platform_default_hermes_home", + lambda: root) + monkeypatch.setenv("HERMES_HOME", str(root)) + + import cron.jobs as jobs + importlib.reload(jobs) + try: + assert jobs.JOBS_FILE.resolve() == (root / "cron" / "jobs.json").resolve() + finally: + monkeypatch.undo() + importlib.reload(jobs) + + +def test_tick_lock_anchors_at_root_under_profile(tmp_path, monkeypatch): + """The cron tick lock must live at <root>/cron/.tick.lock, NOT the profile + dir — otherwise tickers under different profiles grab different locks and + double-fire the (now root-anchored) jobs store (#32091).""" + import importlib + root = tmp_path / "hermes_home" + profile_home = root / "profiles" / "p" + profile_home.mkdir(parents=True) + import hermes_constants + monkeypatch.setattr(hermes_constants, "_get_platform_default_hermes_home", lambda: root) + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + import cron.scheduler as sched + importlib.reload(sched) + try: + # _hermes_home override is None -> uses get_default_hermes_root() + sched._hermes_home = None + lock_dir, lock_file = sched._get_lock_paths() + assert lock_dir.resolve() == (root / "cron").resolve() + assert lock_file.resolve() == (root / "cron" / ".tick.lock").resolve() + assert lock_dir.resolve() != (profile_home / "cron").resolve() + finally: + monkeypatch.undo() + importlib.reload(sched) + + +def test_get_default_hermes_root_docker_layouts(tmp_path, monkeypatch): + """get_default_hermes_root resolves the root for Docker/custom HERMES_HOME + (outside ~/.hermes), so cron storage works in containers.""" + import hermes_constants + native = tmp_path / "native_home" + monkeypatch.setattr(hermes_constants, "_get_platform_default_hermes_home", lambda: native) + + # Docker custom root (outside native): HERMES_HOME itself IS the root. + monkeypatch.setenv("HERMES_HOME", "/opt/data") + assert hermes_constants.get_default_hermes_root() == Path("/opt/data") + + # Docker profile layout: <custom>/profiles/<name> -> <custom>. + monkeypatch.setenv("HERMES_HOME", "/opt/data/profiles/coder") + assert hermes_constants.get_default_hermes_root() == Path("/opt/data")
-
-
#61813 fix(cron): isolate profile store paths by context
files:
cron/jobs.py,cron/scheduler.py,hermes_cli/web_server.py,tests/hermes_cli/test_web_server_cron_profiles.py
14 PRs closed without merging
From the close comments: superseded by another PR 4 · done elsewhere 2 · closed without comment 1 · duplicate 1 · merged via another change 1 · partial fix 1 · stale or abandoned 1 · other 3
- #25323 Fix cron paths for runtime profile overrides · other
-
#29339 fix(cron): resolve jobs path dynamically to prevent profile fragmentation · done elsewhere →
ec0227b,#61813 - #32059 fix(cli): keep --profile flag on cron create|add|edit subparser (#32046) · stale or abandoned
-
#32081 fix(cli): skip profile override for cron commands (#32046) · superseded by another PR →
#50112,#32091,#32046 -
#32117 fix(cron): use get_default_hermes_root for JOBS_FILE so profile-scoped agents don't orphan jobs (fixes #32091) · other →
#50112
Show 9 more
-
#33839 fix(cron): keep profile-created jobs visible to the gateway · superseded by another PR →
#50112,#48649 -
#43556 fix(skills): resolve stale HERMES_HOME-scoped skill paths · superseded by another PR →
f8723c4,c6a3d41,cc421cb - #43614 fix(tools): dynamic skills_dir resolution for profile context changes · closed without comment
-
#43649 fix(skills): resolve SKILLS_DIR dynamically to avoid stale paths (#43548) · superseded by another PR →
#43614,#43548 - #45350 fix(cron): add --profile flag to all cron subcommands · other
-
#48684 fix(cron): resolve cron + skills paths against the active profile HOME (#48649) · done elsewhere →
f8723c4,ec0227b -
#48719 fix(cron): make cron jobs and skills profile-aware (#48649) · duplicate →
#48684,#48649 -
#56689 fix(skills): resolve skills dir from active profile · merged via another change →
#60180,#40959,#40677 -
#77404 fix(cron): cron edit --profile pins job to a profile, not a context switch · partial fix →
d73078e,18ca2e4,#32059,#45350,#45451,#77404,#45231,#44918,#60330,#63381,#32045,#32046,#45335,#45139,#73532,#1,#15803,#14532,#6,#9,#374151,#4338
Closed issues · 8
- Closed#20622[Feature]: Multi-profile dashboard support - surface cron jobs across all profiles in one view
- Closed#22021[Feature]: make profile cron jobs display in the dashboard
- Closed#25290[BUG] Cron jobs split across profile directories — created in wrong profile, not visible to gateway
- Closed#25295Cron jobs created via CLI silently fail to fire — profile fragmentation
- Closed#32091Cron jobs created from a profile-scoped agent session go to a jobs.json the gateway never reads (silent orphan)
- Closed#43548[Bug]: Skill resolution uses stale SKILLS_DIR after profile/HERMES_HOME context changes
- Closed#48649Cron jobs not profile-aware: skills and storage use global paths
- Closed#65348Cron jobs never fire — scheduler ticks global ~/.hermes/cron/ but jobs live in profile dir
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.06: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab