# NousResearch/hermes-agent · Cron and skills path handling across profiles

## Open now

- 4 issues, 2 PRs open on GitHub.
- #32045: \[Bug\] \`hermes cron edit <id> --profile <name>\` returns "Job not found" for existing jobs
- #32046: \[Bug\] \`hermes cron create --profile <name>\` switches HERMES_HOME instead of pinning — cron lands in target profile's jobs.json
- #45335: hermes cron edit --profile <name> returns "Job not found" for all jobs
- #54650: Cron scheduler ignores job profile — always runs as default identity
- PR #45451: fix(cron): \`hermes cron edit --profile <name>\` now sets job profile field
- PR #54687: fix(cron): honor job profile field for SOUL.md and skills

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.

## TL;DR

- 30 member(s): 4 open issue(s), 8 closed issue(s), 2 open PR(s), 2 merged PR(s), 14 PR(s) closed without merging.
- A fix was merged to main in PR #50112. Verify it addresses your case.
- **Recommended:** read PR #50112 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-50112`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Open related issues

- #32045: \[Bug\] \`hermes cron edit <id> --profile <name>\` returns "Job not found" for existing jobs
- #32046: \[Bug\] \`hermes cron create --profile <name>\` switches HERMES_HOME instead of pinning — cron lands in target profile's jobs.json
- #45335: hermes cron edit --profile <name> returns "Job not found" for all jobs
- #54650: Cron scheduler ignores job profile — always runs as default identity

## Closed / duplicate issues

- #20622: \[Feature\]: Multi-profile dashboard support - surface cron jobs across all profiles in one view
- #22021: \[Feature\]: make profile cron jobs display in the dashboard
- #25290: \[BUG\] Cron jobs split across profile directories — created in wrong profile, not visible to gateway
- #25295: Cron jobs created via CLI silently fail to fire — profile fragmentation
- #32091: Cron jobs created from a profile-scoped agent session go to a jobs.json the gateway never reads (silent orphan)
- #43548: \[Bug\]: Skill resolution uses stale SKILLS_DIR after profile/HERMES_HOME context changes
- #48649: Cron jobs not profile-aware: skills and storage use global paths
- #65348: Cron jobs never fire — scheduler ticks global ~/.hermes/cron/ but jobs live in profile dir

## Prior & competing PRs

- **#32117: fix(cron): use get_default_hermes_root for JOBS_FILE so profile-scoped agents don't orphan jobs (fixes #32091)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#50112`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#43649: fix(skills): resolve SKILLS_DIR dynamically to avoid stale paths (#43548)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#43614`, `#43548`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#48684: fix(cron): resolve cron + skills paths against the active profile HOME (#48649)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `f8723c47818e84df7d4514228381be5b0509b7f3`, `ec0227b4350415ee500626a53a27e15a09332e1b`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#50112: fix(cron): anchor cron storage at the default root home (#32091)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
  - fix hunks:
  - `cron/jobs.py`

    ```diff
    @@ -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__)
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cron/jobs.py`, `cron/scheduler.py`, `hermes_cli/web_server.py`, `tests/hermes_cli/test_web_server_cron_profiles.py`
- **#25323: Fix cron paths for runtime profile overrides**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`
- **#29339: fix(cron): resolve jobs path dynamically to prevent profile fragmentation**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `ec0227b4350415ee500626a53a27e15a09332e1b`, `ec0227b4350415ee500626a53a27e15a09332e1b`, `#61813`
- **#32059: fix(cli): keep --profile flag on cron create|add|edit subparser (#32046)**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#32081: fix(cli): skip profile override for cron commands (#32046)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#50112`, `#32091`, `#32046`, `#32046`, `#32091`
- **#33839: fix(cron): keep profile-created jobs visible to the gateway**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#50112`, `#48649`, `#50112`
- **#43556: fix(skills): resolve stale HERMES_HOME-scoped skill paths**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `f8723c4781`, `c6a3d412d4`, `cc421cb697`
- **#43614: fix(tools): dynamic skills_dir resolution for profile context changes**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#45350: fix(cron): add --profile flag to all cron subcommands**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`
- **#45451: fix(cron): \`hermes cron edit --profile <name>\` now sets job profile field**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `cron/jobs.py`, `hermes_cli/cli_commands_mixin.py`, `hermes_cli/cron.py`, `hermes_cli/main.py`, `hermes_cli/subcommands/cron.py`, `tests/cron/test_jobs.py`, `tests/hermes_cli/test_apply_profile_override.py`, `tests/hermes_cli/test_cron.py`, `tests/hermes_cli/test_cron_parser_builder.py`, `tools/cronjob_tools.py`
- **#48719: fix(cron): make cron jobs and skills profile-aware (#48649)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#48684`, `#48649`, `#48684`
- **#54687: fix(cron): honor job profile field for SOUL.md and skills**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `7d8d000b1921cb1c14aa137a41bd96203992e2c0`
  - files: `cron/scheduler.py`
- **#56689: fix(skills): resolve skills dir from active profile**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#60180`, `#40959`, `#40677`
- **#77404: fix(cron): cron edit --profile pins job to a profile, not a context switch**. closed without merge
  - maintainer direction: category `partial_fix`; implication `needs_llm`; refs `d73078e7b`, `18ca2e423f8e`, `#32059`, `#45350`, `#45451`, `#77404`, `#45231`, `#44918`, `#60330`, `#63381`, `#32059`, `#32045`, `#32046`, `#45335`, `#44918`, `#45231`, `#45139`, `#45350`, `#45231`, `#44918`, `#45451`, `#32045`, `#45335`, `#77404`, `#77404`, `#60330`, `#63381`, `#73532`, `#77404`, `#45451`, `#32059`, `#45335`, `#32045`, `#45451`, `#77404`, `#32059`, `#44918`, `#45231`, `#45350`, `#45350`, `#45231`, `#77404`, `#32059`, `#45451`, `#77404`, `#32059`, `#45350`, `#44918`, `#45231`, `#60330`, `#63381`, `#1`, `#1`, `#15803`, `#14532`, `#6`, `#1`, `#9`, `#374151`, `#4338`, `#32045`, `#32046`, `#45335`, `#32059`, `#45451`, `#77404`

## Fix status

- Merged to main in PR #50112 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-10`.
- Subsystem: `cron`
