From bb7ff7dc302cbcbe41cf6bc09424ffc9fb2d062f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:53:50 -0700 Subject: [PATCH] revert(cron): return cron job storage to per-profile (reverts #32117 + #50993) (#51116) * Revert "fix(cron): scope job execution to its owning profile (#32091 follow-up) (#50993)" This reverts commit 660e36f097e8bc0c2dc2a9e22d203eb6a9d9361c. * Revert "fix(cron): anchor cron storage at the default root home (not the active profile)" This reverts commit a5c09fd176627cce350ef1b30dcd8528f9e7c775. --- cron/jobs.py | 95 +--------- cron/scheduler.py | 79 +------- cron/suggestions.py | 4 +- hermes_cli/cron.py | 7 - hermes_cli/subcommands/cron.py | 4 - tests/cron/test_claim_job_for_fire.py | 5 +- tests/cron/test_cron_profile_storage.py | 241 ------------------------ tools/cronjob_tools.py | 2 - 8 files changed, 14 insertions(+), 423 deletions(-) delete mode 100644 tests/cron/test_cron_profile_storage.py diff --git a/cron/jobs.py b/cron/jobs.py index 7a117c37775..ed0ac61fb21 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -31,7 +31,7 @@ except ImportError: # pragma: no cover - non-Windows msvcrt = None from datetime import datetime, timedelta from pathlib import Path -from hermes_constants import get_default_hermes_root, get_hermes_home +from hermes_constants import get_hermes_home from typing import Optional, Dict, List, Any, Union logger = logging.getLogger(__name__) @@ -49,7 +49,7 @@ except ImportError: # Configuration # ============================================================================= -HERMES_DIR = get_default_hermes_root().resolve() +HERMES_DIR = get_hermes_home().resolve() CRON_DIR = HERMES_DIR / "cron" JOBS_FILE = CRON_DIR / "jobs.json" # Heartbeat file the in-process ticker touches on every loop iteration. The @@ -248,12 +248,6 @@ def _normalize_job_record(job: Dict[str, Any]) -> Dict[str, Any]: state = "scheduled" if normalized.get("enabled", True) else "paused" normalized["state"] = state - # Legacy jobs (created before per-job profile scoping) have no profile - # field. Default them to "default" so the scheduler treats them as - # root-profile jobs — matching their pre-existing behaviour. - prof = normalized.get("profile") - normalized["profile"] = (str(prof).strip() if isinstance(prof, str) and prof.strip() else "default") - return normalized @@ -274,43 +268,6 @@ def _secure_file(path: Path): pass -def current_profile_name() -> str: - """Return the active profile name for the process creating a job. - - ``~/.hermes`` -> ``"default"`` - ``~/.hermes/profiles/X`` -> ``"X"`` - - Used at create time to tag a job with the profile whose environment - (.env / config.yaml / credentials) it should execute under, so the - job runs as its owning profile regardless of which profile's ticker - picks it up from the shared root store (#32091). - """ - try: - from agent.file_safety import _resolve_active_profile_name - return _resolve_active_profile_name() or "default" - except Exception: - return "default" - - -def resolve_profile_home(profile_name: Optional[str]) -> Optional[Path]: - """Map a job's ``profile`` name to the HERMES_HOME it should run under. - - ``"default"`` / empty / ``None`` -> the root home (``get_default_hermes_root()``). - ``""`` -> ``/profiles/``. - - Returns ``None`` when the named profile directory does not exist, so the - scheduler can fall back to the ticker's own home and log a warning rather - than pointing a job at a missing profile. - """ - name = (profile_name or "").strip() - if not name or name == "default": - return get_default_hermes_root().resolve() - candidate = (get_default_hermes_root() / "profiles" / name).resolve() - if candidate.is_dir(): - return candidate - return None - - def ensure_dirs(): """Ensure cron directories exist with secure permissions.""" CRON_DIR.mkdir(parents=True, exist_ok=True) @@ -658,44 +615,10 @@ 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 @@ -815,7 +738,6 @@ def create_job( enabled_toolsets: Optional[List[str]] = None, workdir: Optional[str] = None, no_agent: bool = False, - profile: Optional[str] = None, ) -> Dict[str, Any]: """ Create a new cron job. @@ -860,13 +782,6 @@ def create_job( and deliver its stdout directly. Empty stdout = silent (no delivery). Requires ``script`` to be set. Ideal for classic watchdogs and periodic alerts that don't need LLM reasoning. - profile: Optional Hermes profile name the job should EXECUTE under - (its .env / config.yaml / credentials). Defaults to the active - profile of the session creating the job. The shared root store - holds every profile's jobs (#32091); this field is what scopes - a job's runtime environment to its owning profile so it runs - with that profile's permissions regardless of which ticker - picks it up. Returns: The created job dict @@ -901,11 +816,6 @@ def create_job( normalized_toolsets = normalized_toolsets or None normalized_workdir = _normalize_workdir(workdir) normalized_no_agent = bool(no_agent) - # Tag the job with the profile whose environment it should execute under. - # When the caller does not pass one explicitly, capture the active profile - # of the session creating the job so a job created under `hermes -p donna` - # runs as donna even though it now lives in the shared root store (#32091). - normalized_profile = (str(profile).strip() if isinstance(profile, str) else "") or current_profile_name() # no_agent jobs are meaningless without a script — the script IS the job. # Surface this as a clear ValueError at create time so bad configs never @@ -959,7 +869,6 @@ def create_job( "origin": origin, # Tracks where job was created for "origin" delivery "enabled_toolsets": normalized_toolsets, "workdir": normalized_workdir, - "profile": normalized_profile, } with _jobs_lock(): diff --git a/cron/scheduler.py b/cron/scheduler.py index eee3bc1656f..bcdaaa65218 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -316,17 +316,9 @@ 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. - - 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" + """Resolve cron lock paths at call time so profile/env changes are honored.""" + hermes_home = _get_hermes_home() + lock_dir = hermes_home / "cron" return lock_dir, lock_dir / ".tick.lock" @@ -1857,32 +1849,6 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: os.environ["TERMINAL_CWD"] = _job_workdir logger.info("Job '%s': using workdir %s", job_id, _job_workdir) - # Scope this job's execution to its owning profile's HERMES_HOME (#32091). - # The shared root store holds every profile's jobs, but a job must run with - # the .env / config.yaml / credentials of the profile that created it — not - # whichever profile's ticker happened to pick it up. We set both the - # in-process ContextVar override (consumed by _get_hermes_home() for the - # config/.env/script loads below) AND os.environ["HERMES_HOME"] (inherited - # by any child subprocess the agent spawns). tick() routes profile-scoped - # jobs to the single-worker sequential pool, so mutating os.environ here is - # safe — they never overlap. Restored in the finally block. - from cron.jobs import resolve_profile_home - from hermes_constants import set_hermes_home_override - _job_profile = (job.get("profile") or "default").strip() or "default" - _profile_home = resolve_profile_home(_job_profile) - _prior_hermes_home = os.environ.get("HERMES_HOME", "_UNSET_") - _hermes_home_token = None - if _profile_home is not None and _profile_home != _get_hermes_home().resolve(): - os.environ["HERMES_HOME"] = str(_profile_home) - _hermes_home_token = set_hermes_home_override(str(_profile_home)) - logger.info("Job '%s': executing under profile %r (HERMES_HOME=%s)", - job_id, _job_profile, _profile_home) - elif _profile_home is None and _job_profile != "default": - logger.warning( - "Job '%s': profile %r no longer exists — running under the " - "ticker's profile instead", job_id, _job_profile, - ) - try: # Re-read .env and config.yaml fresh every run so provider/key # changes take effect without a gateway restart. @@ -2294,19 +2260,6 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: os.environ.pop("TERMINAL_CWD", None) else: os.environ["TERMINAL_CWD"] = _prior_terminal_cwd - # Restore HERMES_HOME to the ticker's value when this job overrode it - # for profile-scoped execution (#32091). Mirrors the TERMINAL_CWD - # restore above; the sequential pool guarantees no overlap. - if _hermes_home_token is not None: - try: - from hermes_constants import reset_hermes_home_override - reset_hermes_home_override(_hermes_home_token) - except Exception: - pass - if _prior_hermes_home == "_UNSET_": - os.environ.pop("HERMES_HOME", None) - else: - os.environ["HERMES_HOME"] = _prior_hermes_home # Clean up ContextVar session/delivery state for this job. clear_session_vars(_ctx_tokens) for _var_name in _cron_delivery_vars: @@ -2512,26 +2465,12 @@ def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> i body.""" return run_one_job(job, adapters=adapters, loop=loop, verbose=verbose) - # Partition due jobs: those that mutate process-global os.environ - # inside run_job MUST run sequentially to avoid corrupting each other. - # Two cases mutate env: - # - a per-job workdir sets os.environ["TERMINAL_CWD"]. - # - a per-job profile whose HERMES_HOME differs from the ticker's - # sets os.environ["HERMES_HOME"] to scope execution (#32091). - # Jobs that need neither leave env untouched and stay parallel-safe. - def _needs_sequential(j: dict) -> bool: - if (j.get("workdir") or "").strip(): - return True - prof = (j.get("profile") or "default").strip() or "default" - try: - from cron.jobs import resolve_profile_home - phome = resolve_profile_home(prof) - except Exception: - phome = None - return phome is not None and phome != _get_hermes_home().resolve() - - sequential_jobs = [j for j in due_jobs if _needs_sequential(j)] - parallel_jobs = [j for j in due_jobs if not _needs_sequential(j)] + # Partition due jobs: those with a per-job workdir mutate + # os.environ["TERMINAL_CWD"] inside run_job, which is process-global — + # so they MUST run sequentially to avoid corrupting each other. Jobs + # without a workdir leave env untouched and stay parallel-safe. + sequential_jobs = [j for j in due_jobs if (j.get("workdir") or "").strip()] + parallel_jobs = [j for j in due_jobs if not (j.get("workdir") or "").strip()] _results: list = [] _all_futures: list = [] diff --git a/cron/suggestions.py b/cron/suggestions.py index 6c10a4f5b28..636a0335cc3 100644 --- a/cron/suggestions.py +++ b/cron/suggestions.py @@ -36,13 +36,13 @@ import uuid from pathlib import Path from typing import Any, Dict, List, Optional -from hermes_constants import get_default_hermes_root +from hermes_constants import get_hermes_home from hermes_time import now as _hermes_now from utils import atomic_replace logger = logging.getLogger(__name__) -CRON_DIR = get_default_hermes_root().resolve() / "cron" +CRON_DIR = get_hermes_home().resolve() / "cron" SUGGESTIONS_FILE = CRON_DIR / "suggestions.json" # In-process lock protecting load->modify->save cycles (the background review diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index 44792fa630c..3c3116970a7 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -120,9 +120,6 @@ def cron_list(show_all: bool = False): workdir = job.get("workdir") if workdir: print(f" Workdir: {workdir}") - _prof = job.get("profile") - if _prof and _prof != "default": - print(f" Profile: {_prof}") # Execution history last_status = job.get("last_status") @@ -262,7 +259,6 @@ def cron_create(args): script=getattr(args, "script", None), workdir=getattr(args, "workdir", None), no_agent=getattr(args, "no_agent", False) or None, - profile=getattr(args, "profile", None), ) if not result.get("success"): print(color(f"Failed to create job: {result.get('error', 'unknown error')}", Colors.RED)) @@ -279,9 +275,6 @@ def cron_create(args): print(" Mode: no-agent (script stdout delivered directly)") if job_data.get("workdir"): print(f" Workdir: {job_data['workdir']}") - _prof = job_data.get("profile") - if _prof and _prof != "default": - print(f" Profile: {_prof}") print(f" Next run: {result['next_run_at']}") return 0 diff --git a/hermes_cli/subcommands/cron.py b/hermes_cli/subcommands/cron.py index 7ceea3a0f58..c50b3401462 100644 --- a/hermes_cli/subcommands/cron.py +++ b/hermes_cli/subcommands/cron.py @@ -70,10 +70,6 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None: "--workdir", help="Absolute path for the job to run from. Injects AGENTS.md / CLAUDE.md / .cursorrules from that directory and uses it as the cwd for terminal/file/code_exec tools. Omit to preserve old behaviour (no project context files).", ) - cron_create.add_argument( - "--profile", - help="Hermes profile the job should EXECUTE under (its .env / config.yaml / credentials). Defaults to the profile that created the job. Jobs live in one shared root store (#32091); this scopes a job's runtime environment to the named profile so it runs with that profile's permissions.", - ) # cron edit cron_edit = cron_subparsers.add_parser( diff --git a/tests/cron/test_claim_job_for_fire.py b/tests/cron/test_claim_job_for_fire.py index a02b1110381..abbe969eb04 100644 --- a/tests/cron/test_claim_job_for_fire.py +++ b/tests/cron/test_claim_job_for_fire.py @@ -14,10 +14,7 @@ import pytest 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)) - # 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. + # cron.jobs caches no home at import; get_hermes_home() reads the env live. yield tmp_path diff --git a/tests/cron/test_cron_profile_storage.py b/tests/cron/test_cron_profile_storage.py deleted file mode 100644 index 53d0feec912..00000000000 --- a/tests/cron/test_cron_profile_storage.py +++ /dev/null @@ -1,241 +0,0 @@ -"""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/

/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 (/profiles/), the cron store - resolves to /cron, NOT /profiles//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 /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 (/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 - /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 /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: /profiles/ -> . - monkeypatch.setenv("HERMES_HOME", "/opt/data/profiles/coder") - assert hermes_constants.get_default_hermes_root() == Path("/opt/data") - - -# --------------------------------------------------------------------------- -# Per-job profile EXECUTION scoping (#32091 follow-up). -# -# The storage half of #32091 (above) moved every profile's jobs into one shared -# root store. But a job must still EXECUTE under its owning profile's -# environment (.env / config.yaml / credentials) — not whichever profile's -# ticker picks it up. These tests cover the execution-scoping half. -# --------------------------------------------------------------------------- - - -def _profile_env(tmp_path, monkeypatch, active="default"): - """Set up a root home with a 'donna' profile dir and point the platform - default at it. Returns (root, donna_home). ``active`` selects which - HERMES_HOME the process runs under.""" - root = tmp_path / "hermes_home" - (root / "cron").mkdir(parents=True) - donna_home = root / "profiles" / "donna" - (donna_home / "cron").mkdir(parents=True) - import hermes_constants - monkeypatch.setattr(hermes_constants, "_get_platform_default_hermes_home", - lambda: root) - monkeypatch.setenv("HERMES_HOME", str(root if active == "default" else donna_home)) - return root, donna_home - - -def test_create_job_autocaptures_active_profile(tmp_path, monkeypatch): - """A job created from inside a profile session is tagged with that profile, - so the scheduler can later scope its execution back to it.""" - root, donna_home = _profile_env(tmp_path, monkeypatch, active="donna") - import cron.jobs as jobs - importlib.reload(jobs) - try: - job = jobs.create_job(prompt="audit", schedule="every 1h", name="a") - # auto-captured from the active (donna) session - assert job["profile"] == "donna" - # and it landed in the SHARED ROOT store, not donna's profile-local one - assert jobs.JOBS_FILE.resolve() == (root / "cron" / "jobs.json").resolve() - assert jobs.JOBS_FILE.exists() - assert not (donna_home / "cron" / "jobs.json").exists() - finally: - monkeypatch.undo() - importlib.reload(jobs) - - -def test_create_job_explicit_profile_override(tmp_path, monkeypatch): - """An explicit profile= wins over the auto-captured active profile.""" - root, donna_home = _profile_env(tmp_path, monkeypatch, active="default") - (root / "profiles" / "ops" / "cron").mkdir(parents=True) - import cron.jobs as jobs - importlib.reload(jobs) - try: - job = jobs.create_job(prompt="x", schedule="every 2h", profile="ops") - assert job["profile"] == "ops" - finally: - monkeypatch.undo() - importlib.reload(jobs) - - -def test_resolve_profile_home_maps_names(tmp_path, monkeypatch): - """resolve_profile_home maps default/named profiles to homes and returns - None for a missing profile.""" - root, donna_home = _profile_env(tmp_path, monkeypatch, active="default") - import cron.jobs as jobs - importlib.reload(jobs) - try: - assert jobs.resolve_profile_home("default").resolve() == root.resolve() - assert jobs.resolve_profile_home("").resolve() == root.resolve() - assert jobs.resolve_profile_home("donna").resolve() == donna_home.resolve() - assert jobs.resolve_profile_home("ghost") is None - finally: - monkeypatch.undo() - importlib.reload(jobs) - - -def test_normalize_backfills_legacy_profile_to_default(tmp_path, monkeypatch): - """A pre-feature job with no profile field reads back as 'default'.""" - import cron.jobs as jobs - legacy = {"id": "l1", "name": "old", "prompt": "x", - "schedule": {"kind": "interval", "minutes": 60}} - assert jobs._normalize_job_record(legacy)["profile"] == "default" - - -def test_run_job_scopes_execution_to_job_profile(tmp_path, monkeypatch): - """The decisive test: a ticker running as the ROOT profile executes a - job tagged profile='donna' with HERMES_HOME pointed at donna's home - (both the env var and the in-process override), then restores the - ticker's env afterward.""" - from unittest.mock import MagicMock, patch - root, donna_home = _profile_env(tmp_path, monkeypatch, active="default") - (donna_home / "config.yaml").write_text("model:\n default: openrouter/test\n") - - import hermes_constants - import cron.jobs as jobs - import cron.scheduler as sched - importlib.reload(jobs) - importlib.reload(sched) - - captured = {} - - def fake_run_conversation(prompt, *a, **k): - captured["env"] = os.environ.get("HERMES_HOME") - captured["override"] = hermes_constants.get_hermes_home_override() - captured["resolved"] = str(hermes_constants.get_hermes_home()) - return {"final_response": "done", "completed": True, "failed": False, - "turn_exit_reason": "text_response(finish_reason=stop)"} - - job = {"id": "j-donna", "name": "donna-audit", "prompt": "audit", - "profile": "donna", "schedule": {"kind": "interval", "minutes": 60}, - "deliver": "local", "model": "openrouter/test"} - - before = os.environ.get("HERMES_HOME") - try: - fake_agent = MagicMock() - fake_agent.run_conversation.side_effect = fake_run_conversation - with patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ - patch("hermes_state.SessionDB", return_value=MagicMock()), \ - patch("hermes_cli.runtime_provider.resolve_runtime_provider", - return_value={"api_key": "k", "base_url": "https://x/v1", - "provider": "openrouter", "api_mode": "chat_completions"}), \ - patch("run_agent.AIAgent", return_value=fake_agent): - success, output, final, err = sched.run_job(job) - - assert success is True, (success, err) - # During execution the job ran AS donna: - assert captured["env"] == str(donna_home) - assert captured["override"] == str(donna_home) - assert captured["resolved"] == str(donna_home) - # After the job, the ticker's HERMES_HOME is restored (no leak): - assert os.environ.get("HERMES_HOME") == before - finally: - monkeypatch.undo() - importlib.reload(jobs) - importlib.reload(sched) diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 62f677bc912..3339b823941 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -539,7 +539,6 @@ def cronjob( enabled_toolsets: Optional[List[str]] = None, workdir: Optional[str] = None, no_agent: Optional[bool] = None, - profile: Optional[str] = None, task_id: str = None, ) -> str: """Unified cron job management tool.""" @@ -606,7 +605,6 @@ def cronjob( enabled_toolsets=enabled_toolsets or None, workdir=_normalize_optional_job_value(workdir), no_agent=_no_agent, - profile=_normalize_optional_job_value(profile), ) _notify_provider_jobs_changed_safe() return json.dumps(