{"repo":"NousResearch/hermes-agent","public_id":"20622","display_label":"Cron and skills path handling across profiles","summary":"These 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.","subsystem":"cron","counts":{"members":30,"open_issues":4,"open_issues_distinct":4,"open_prs":2,"closed_issues":8,"merged_prs":2,"attempt_prs":16},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50112},"resolution":{"state":"partially_resolved","by":"pr-50112"},"freshness":{"state":"verified","last_verified_at":"2026-07-10T06:13:11+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.05903448275862067},"fix_edges":[{"issue_number":25295,"pr_number":61813,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":32091,"pr_number":50112,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":61768,"pr_number":61813,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":20622,"title":"[Feature]: Multi-profile dashboard support - surface cron jobs across all profiles in one view","url":null,"state":"closed"},{"kind":"issue","number":22021,"title":"[Feature]: make profile cron jobs display in the dashboard","url":null,"state":"closed"},{"kind":"issue","number":25290,"title":"[BUG] Cron jobs split across profile directories — created in wrong profile, not visible to gateway","url":null,"state":"closed"},{"kind":"issue","number":25295,"title":"Cron jobs created via CLI silently fail to fire — profile fragmentation","url":null,"state":"closed"},{"kind":"issue","number":32045,"title":"[Bug] `hermes cron edit <id> --profile <name>` returns \"Job not found\" for existing jobs","url":null,"state":"open"},{"kind":"issue","number":32046,"title":"[Bug] `hermes cron create --profile <name>` switches HERMES_HOME instead of pinning — cron lands in target profile's jobs.json","url":null,"state":"open"},{"kind":"issue","number":32091,"title":"Cron jobs created from a profile-scoped agent session go to a jobs.json the gateway never reads (silent orphan)","url":null,"state":"closed"},{"kind":"issue","number":43548,"title":"[Bug]: Skill resolution uses stale SKILLS_DIR after profile/HERMES_HOME context changes","url":null,"state":"closed"},{"kind":"issue","number":45335,"title":"hermes cron edit --profile <name> returns \"Job not found\" for all jobs","url":null,"state":"open"},{"kind":"issue","number":48649,"title":"Cron jobs not profile-aware: skills and storage use global paths","url":null,"state":"closed"},{"kind":"issue","number":54650,"title":"Cron scheduler ignores job profile — always runs as default identity","url":null,"state":"open"},{"kind":"issue","number":65348,"title":"Cron jobs never fire — scheduler ticks global ~/.hermes/cron/ but jobs live in profile dir","url":null,"state":"closed"},{"kind":"pr","number":25323,"title":"Fix cron paths for runtime profile overrides","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":29339,"title":"fix(cron): resolve jobs path dynamically to prevent profile fragmentation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ec0227b4350415ee500626a53a27e15a09332e1b","number":0},{"kind":"commit","value":"ec0227b4350415ee500626a53a27e15a09332e1b","number":0},{"kind":"pr","value":"#61813","number":61813}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":32059,"title":"fix(cli): keep --profile flag on cron create|add|edit subparser (#32046)","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":32081,"title":"fix(cli): skip profile override for cron commands (#32046)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50112","number":50112},{"kind":"pr","value":"#32091","number":32091},{"kind":"pr","value":"#32046","number":32046},{"kind":"pr","value":"#32046","number":32046},{"kind":"pr","value":"#32091","number":32091}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":32117,"title":"fix(cron): use get_default_hermes_root for JOBS_FILE so profile-scoped agents don't orphan jobs (fixes #32091)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50112","number":50112}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":33839,"title":"fix(cron): keep profile-created jobs visible to the gateway","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50112","number":50112},{"kind":"pr","value":"#48649","number":48649},{"kind":"pr","value":"#50112","number":50112}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":43556,"title":"fix(skills): resolve stale HERMES_HOME-scoped skill paths","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f8723c4781","number":0},{"kind":"commit","value":"c6a3d412d4","number":0},{"kind":"commit","value":"cc421cb697","number":0}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":43614,"title":"fix(tools): dynamic skills_dir resolution for profile context changes","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":43649,"title":"fix(skills): resolve SKILLS_DIR dynamically to avoid stale paths (#43548)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#43614","number":43614},{"kind":"pr","value":"#43548","number":43548}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":45350,"title":"fix(cron): add --profile flag to all cron subcommands","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":45451,"title":"fix(cron): `hermes cron edit --profile <name>` now sets job profile field","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":48684,"title":"fix(cron): resolve cron + skills paths against the active profile HOME (#48649)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f8723c47818e84df7d4514228381be5b0509b7f3","number":0},{"kind":"commit","value":"ec0227b4350415ee500626a53a27e15a09332e1b","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":48719,"title":"fix(cron): make cron jobs and skills profile-aware (#48649)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#48684","number":48684},{"kind":"pr","value":"#48649","number":48649},{"kind":"pr","value":"#48684","number":48684}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":50112,"title":"fix(cron): anchor cron storage at the default root home (#32091)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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.py"],"fix_hunks":[{"file":"cron/jobs.py","hunks":["@@ -31,7 +31,7 @@\n     msvcrt = None\n from datetime import datetime, timedelta\n from pathlib import Path\n-from hermes_constants import get_hermes_home\n+from hermes_constants import get_default_hermes_root, get_hermes_home\n from typing import Optional, Dict, List, Any, Union\n \n logger = logging.getLogger(__name__)","@@ -49,7 +49,7 @@\n # Configuration\n # =============================================================================\n \n-HERMES_DIR = get_hermes_home().resolve()\n+HERMES_DIR = get_default_hermes_root().resolve()\n CRON_DIR = HERMES_DIR / \"cron\"\n JOBS_FILE = CRON_DIR / \"jobs.json\"\n # Heartbeat file the in-process ticker touches on every loop iteration. The","@@ -615,10 +615,44 @@ def get_ticker_success_age() -> Optional[float]:\n # Job CRUD Operations\n # =============================================================================\n \n+_WARNED_ORPHAN_STORE = False\n+\n+\n+def _warn_if_orphaned_profile_store() -> None:\n+    \"\"\"Loudly warn (once) if the root store is empty but a profile-local\n+    jobs.json exists from before #32091's root-anchoring fix.\n+\n+    Such a file is now unreachable (the store anchors at the default root, not\n+    the active profile). The jobs in it were already orphaned pre-fix (the\n+    profile-less gateway never read them), so this is not a regression — but a\n+    user who could SEE them in `cron list` under their profile would otherwise\n+    find them silently gone. Point them at the path instead of failing silent.\n+    \"\"\"\n+    global _WARNED_ORPHAN_STORE\n+    if _WARNED_ORPHAN_STORE:\n+        return\n+    try:\n+        active = get_hermes_home().resolve()\n+        if active == HERMES_DIR:\n+            return  # not in a profile; nothing could be orphaned\n+        legacy = active / \"cron\" / \"jobs.json\"\n+        if legacy.exists():\n+            _WARNED_ORPHAN_STORE = True\n+            logger.warning(\n+                \"Cron jobs now live at %s (shared across profiles). A legacy \"\n+                \"profile-local store exists at %s and is no longer read; \"\n+                \"re-create those jobs or move them into the root store. (#32091)\",\n+                JOBS_FILE, legacy,\n+            )\n+    except Exception:\n+        pass  # best-effort advisory; never block load_jobs\n+\n+\n def load_jobs() -> List[Dict[str, Any]]:\n     \"\"\"Load all jobs from storage.\"\"\"\n     ensure_dirs()\n     if not JOBS_FILE.exists():\n+        _warn_if_orphaned_profile_store()\n         return []\n \n     _strict_retry = False  # track whether we used the strict=False fallback"]},{"file":"cron/scheduler.py","hunks":["@@ -283,9 +283,17 @@ def _get_hermes_home() -> Path:\n \n \n def _get_lock_paths() -> tuple[Path, Path]:\n-    \"\"\"Resolve cron lock paths at call time so profile/env changes are honored.\"\"\"\n-    hermes_home = _get_hermes_home()\n-    lock_dir = hermes_home / \"cron\"\n+    \"\"\"Resolve cron lock paths at call time so profile/env changes are honored.\n+\n+    Anchored on the DEFAULT ROOT home (not the active profile), matching the\n+    jobs store in cron.jobs (which uses get_default_hermes_root). The tick lock\n+    is storage-coordination — it must live next to the single jobs.json so that\n+    tickers running under different profiles share one lock and can't\n+    double-fire the relocated store (#32091). Execution context (.env,\n+    config.yaml, scripts) stays profile-aware via _get_hermes_home().\n+    \"\"\"\n+    from hermes_constants import get_default_hermes_root\n+    lock_dir = (_hermes_home or get_default_hermes_root()) / \"cron\"\n     return lock_dir, lock_dir / \".tick.lock\"\n \n "]},{"file":"cron/suggestions.py","hunks":["@@ -36,13 +36,13 @@\n from pathlib import Path\n from typing import Any, Dict, List, Optional\n \n-from hermes_constants import get_hermes_home\n+from hermes_constants import get_default_hermes_root\n from hermes_time import now as _hermes_now\n from utils import atomic_replace\n \n logger = logging.getLogger(__name__)\n \n-CRON_DIR = get_hermes_home().resolve() / \"cron\"\n+CRON_DIR = get_default_hermes_root().resolve() / \"cron\"\n SUGGESTIONS_FILE = CRON_DIR / \"suggestions.json\"\n \n # In-process lock protecting load->modify->save cycles (the background review"]},{"file":"scripts/release.py","hunks":["@@ -1213,6 +1213,7 @@\n     \"[redacted:email]\": \"holynn-q\",\n     \"[redacted:email]\": \"jacdevos\",\n     \"[redacted:email]\": \"liuhao1024\",\n+    \"[redacted:email]\": \"mohamedorigami-jpg\",  # PR #32117 (cron storage root anchor; #32091)\n     \"[redacted:email]\": \"rbrtbn\",\n     \"[redacted:email]\": \"VinciZhu\",\n     \"[redacted:email]\": \"rodrigoeqnit\","]},{"file":"tests/cron/test_claim_job_for_fire.py","hunks":["@@ -14,7 +14,10 @@\n def temp_home(tmp_path, monkeypatch):\n     \"\"\"Isolated HERMES_HOME so jobs.json doesn't touch the real store.\"\"\"\n     monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n-    # cron.jobs caches no home at import; get_hermes_home() reads the env live.\n+    # NOTE: cron.jobs resolves its store paths (JOBS_FILE, CRON_DIR) from\n+    # get_default_hermes_root() at IMPORT time, so setting HERMES_HOME here does\n+    # not re-point an already-imported module's store. These tests exercise the\n+    # claim logic on in-memory job dicts and don't depend on the on-disk path.\n     yield tmp_path\n \n "]},{"file":"tests/cron/test_cron_profile_storage.py","hunks":["@@ -0,0 +1,105 @@\n+\"\"\"Regression tests for #32091 — profile-scoped cron jobs orphaned.\n+\n+Cron storage (CRON_DIR/JOBS_FILE) must anchor at the *default root* Hermes\n+home, not the active profile's home. Otherwise a job created from a\n+profile-scoped agent session writes to ~/.hermes/profiles/<p>/cron/jobs.json,\n+while the profile-less gateway reads only ~/.hermes/cron/jobs.json — the job\n+is silently orphaned (looks healthy in `list`, never fires).\n+\"\"\"\n+import importlib\n+import os\n+from pathlib import Path\n+\n+\n+def test_cron_storage_anchors_at_root_under_profile(tmp_path, monkeypatch):\n+    \"\"\"Under a profile HERMES_HOME (<root>/profiles/<name>), the cron store\n+    resolves to <root>/cron, NOT <root>/profiles/<name>/cron.\"\"\"\n+    root = tmp_path / \"hermes_home\"\n+    profile_home = root / \"profiles\" / \"myprofile\"\n+    profile_home.mkdir(parents=True)\n+\n+    # Pretend the platform default root IS our tmp root, and the active\n+    # HERMES_HOME is a profile under it (the #32091 scenario).\n+    import hermes_constants\n+    monkeypatch.setattr(hermes_constants, \"_get_platform_default_hermes_home\",\n+                        lambda: root)\n+    monkeypatch.setenv(\"HERMES_HOME\", str(profile_home))\n+\n+    # get_default_hermes_root must return the ROOT, not the profile dir.\n+    assert hermes_constants.get_default_hermes_root().resolve() == root.resolve()\n+    # ...while get_hermes_home (used elsewhere) follows the profile override.\n+    assert hermes_constants.get_hermes_home().resolve() == profile_home.resolve()\n+\n+    # cron/jobs.py computes HERMES_DIR from get_default_hermes_root at import,\n+    # so a fresh import under this env anchors the store at <root>/cron.\n+    import cron.jobs as jobs\n+    importlib.reload(jobs)\n+    try:\n+        assert jobs.HERMES_DIR.resolve() == root.resolve()\n+        assert jobs.JOBS_FILE.resolve() == (root / \"cron\" / \"jobs.json\").resolve()\n+        # The orphan path (<profile>/cron/jobs.json) must NOT be the store.\n+        assert jobs.JOBS_FILE.resolve() != (profile_home / \"cron\" / \"jobs.json\").resolve()\n+    finally:\n+        # Restore module state for other tests (reload under the real env).\n+        monkeypatch.undo()\n+        importlib.reload(jobs)\n+\n+\n+def test_cron_storage_unaffected_when_no_profile(tmp_path, monkeypatch):\n+    \"\"\"With no profile (HERMES_HOME == root), behavior is unchanged: store at\n+    <root>/cron.\"\"\"\n+    root = tmp_path / \"hermes_home\"\n+    root.mkdir(parents=True)\n+    import hermes_constants\n+    monkeypatch.setattr(hermes_constants, \"_get_platform_default_hermes_home\",\n+                        lambda: root)\n+    monkeypatch.setenv(\"HERMES_HOME\", str(root))\n+\n+    import cron.jobs as jobs\n+    importlib.reload(jobs)\n+    try:\n+        assert jobs.JOBS_FILE.resolve() == (root / \"cron\" / \"jobs.json\").resolve()\n+    finally:\n+        monkeypatch.undo()\n+        importlib.reload(jobs)\n+\n+\n+def test_tick_lock_anchors_at_root_under_profile(tmp_path, monkeypatch):\n+    \"\"\"The cron tick lock must live at <root>/cron/.tick.lock, NOT the profile\n+    dir — otherwise tickers under different profiles grab different locks and\n+    double-fire the (now root-anchored) jobs store (#32091).\"\"\"\n+    import importlib\n+    root = tmp_path / \"hermes_home\"\n+    profile_home = root / \"profiles\" / \"p\"\n+    profile_home.mkdir(parents=True)\n+    import hermes_constants\n+    monkeypatch.setattr(hermes_constants, \"_get_platform_default_hermes_home\", lambda: root)\n+    monkeypatch.setenv(\"HERMES_HOME\", str(profile_home))\n+    import cron.scheduler as sched\n+    importlib.reload(sched)\n+    try:\n+        # _hermes_home override is None -> uses get_default_hermes_root()\n+        sched._hermes_home = None\n+        lock_dir, lock_file = sched._get_lock_paths()\n+        assert lock_dir.resolve() == (root / \"cron\").resolve()\n+        assert lock_file.resolve() == (root / \"cron\" / \".tick.lock\").resolve()\n+        assert lock_dir.resolve() != (profile_home / \"cron\").resolve()\n+    finally:\n+        monkeypatch.undo()\n+        importlib.reload(sched)\n+\n+\n+def test_get_default_hermes_root_docker_layouts(tmp_path, monkeypatch):\n+    \"\"\"get_default_hermes_root resolves the root for Docker/custom HERMES_HOME\n+    (outside ~/.hermes), so cron storage works in containers.\"\"\"\n+    import hermes_constants\n+    native = tmp_path / \"native_home\"\n+    monkeypatch.setattr(hermes_constants, \"_get_platform_default_hermes_home\", lambda: native)\n+\n+    # Docker custom root (outside native): HERMES_HOME itself IS the root.\n+    monkeypatch.setenv(\"HERMES_HOME\", \"/opt/data\")\n+    assert hermes_constants.get_default_hermes_root() == Path(\"/opt/data\")\n+\n+    # Docker profile layout: <custom>/profiles/<name> -> <custom>.\n+    monkeypatch.setenv(\"HERMES_HOME\", \"/opt/data/profiles/coder\")\n+    assert hermes_constants.get_default_hermes_root() == Path(\"/opt/data\")\n"]}]},{"kind":"pr","number":54687,"title":"fix(cron): honor job profile field for SOUL.md and skills","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"7d8d000b1921cb1c14aa137a41bd96203992e2c0","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":56689,"title":"fix(skills): resolve skills dir from active profile","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#60180","number":60180},{"kind":"pr","value":"#40959","number":40959},{"kind":"pr","value":"#40677","number":40677}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":61813,"title":"fix(cron): isolate profile store paths by context","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/jobs.py","cron/scheduler.py","hermes_cli/web_server.py","tests/hermes_cli/test_web_server_cron_profiles.py"]},{"kind":"pr","number":77404,"title":"fix(cron): cron edit --profile pins job to a profile, not a context switch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d73078e7b","number":0},{"kind":"commit","value":"18ca2e423f8e","number":0},{"kind":"pr","value":"#32059","number":32059},{"kind":"pr","value":"#45350","number":45350},{"kind":"pr","value":"#45451","number":45451},{"kind":"pr","value":"#77404","number":77404},{"kind":"pr","value":"#45231","number":45231},{"kind":"pr","value":"#44918","number":44918},{"kind":"pr","value":"#60330","number":60330},{"kind":"pr","value":"#63381","number":63381},{"kind":"pr","value":"#32059","number":32059},{"kind":"pr","value":"#32045","number":32045},{"kind":"pr","value":"#32046","number":32046},{"kind":"pr","value":"#45335","number":45335},{"kind":"pr","value":"#44918","number":44918},{"kind":"pr","value":"#45231","number":45231},{"kind":"pr","value":"#45139","number":45139},{"kind":"pr","value":"#45350","number":45350},{"kind":"pr","value":"#45231","number":45231},{"kind":"pr","value":"#44918","number":44918},{"kind":"pr","value":"#45451","number":45451},{"kind":"pr","value":"#32045","number":32045},{"kind":"pr","value":"#45335","number":45335},{"kind":"pr","value":"#77404","number":77404},{"kind":"pr","value":"#77404","number":77404},{"kind":"pr","value":"#60330","number":60330},{"kind":"pr","value":"#63381","number":63381},{"kind":"pr","value":"#73532","number":73532},{"kind":"pr","value":"#77404","number":77404},{"kind":"pr","value":"#45451","number":45451},{"kind":"pr","value":"#32059","number":32059},{"kind":"pr","value":"#45335","number":45335},{"kind":"pr","value":"#32045","number":32045},{"kind":"pr","value":"#45451","number":45451},{"kind":"pr","value":"#77404","number":77404},{"kind":"pr","value":"#32059","number":32059},{"kind":"pr","value":"#44918","number":44918},{"kind":"pr","value":"#45231","number":45231},{"kind":"pr","value":"#45350","number":45350},{"kind":"pr","value":"#45350","number":45350},{"kind":"pr","value":"#45231","number":45231},{"kind":"pr","value":"#77404","number":77404},{"kind":"pr","value":"#32059","number":32059},{"kind":"pr","value":"#45451","number":45451},{"kind":"pr","value":"#77404","number":77404},{"kind":"pr","value":"#32059","number":32059},{"kind":"pr","value":"#45350","number":45350},{"kind":"pr","value":"#44918","number":44918},{"kind":"pr","value":"#45231","number":45231},{"kind":"pr","value":"#60330","number":60330},{"kind":"pr","value":"#63381","number":63381},{"kind":"pr","value":"#1","number":1},{"kind":"pr","value":"#1","number":1},{"kind":"pr","value":"#15803","number":15803},{"kind":"pr","value":"#14532","number":14532},{"kind":"pr","value":"#6","number":6},{"kind":"pr","value":"#1","number":1},{"kind":"pr","value":"#9","number":9},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#4338","number":4338},{"kind":"pr","value":"#32045","number":32045},{"kind":"pr","value":"#32046","number":32046},{"kind":"pr","value":"#45335","number":45335},{"kind":"pr","value":"#32059","number":32059},{"kind":"pr","value":"#45451","number":45451},{"kind":"pr","value":"#77404","number":77404}],"category":"partial_fix","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}