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

4open issues
2open PRs
30items grouped
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.

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.

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

  • PR#45451fix(cron): `hermes cron edit --profile <name>` now sets job profile field
  • PR#54687fix(cron): honor job profile field for SOUL.md and skills

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #50112 fix(cron): anchor cron storage at the default root home (#32091)

    merged to main

    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.py

    Show 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

    merged to main

    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.00 means 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

At a glance

Subsystem
cron
Merged PRs
#50112, #61813
Closed issues
8
Attempts
14 PRs closed without merging
Fix link checked
10 Jul 2026

For your agent

The same facts, typed.