From 4c7626280edc58f9c1c021831b9e6f67b7606dca Mon Sep 17 00:00:00 2001 From: Gianfranco Piana <52470719+gianfrancopiana@users.noreply.github.com> Date: Thu, 14 May 2026 16:42:39 -0300 Subject: [PATCH 1/7] feat: add cron job profile support --- cron/jobs.py | 44 +++++ cron/scheduler.py | 79 +++++++- hermes_cli/cron.py | 9 + hermes_cli/main.py | 8 + tests/cron/test_cron_profile.py | 340 ++++++++++++++++++++++++++++++++ tests/hermes_cli/test_cron.py | 6 + tools/cronjob_tools.py | 13 ++ 7 files changed, 489 insertions(+), 10 deletions(-) create mode 100644 tests/cron/test_cron_profile.py diff --git a/cron/jobs.py b/cron/jobs.py index c5da32d44d50..6d7845c496c2 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -128,6 +128,9 @@ def _normalize_job_record(job: Dict[str, Any]) -> Dict[str, Any]: state = "scheduled" if normalized.get("enabled", True) else "paused" normalized["state"] = state + profile = _coerce_job_text(normalized.get("profile")).strip() + normalized["profile"] = profile or None + return normalized @@ -479,6 +482,30 @@ def _normalize_workdir(workdir: Optional[str]) -> Optional[str]: return str(resolved) +def _normalize_profile(profile: Optional[str]) -> Optional[str]: + """Normalize and validate an optional cron job profile name. + + Empty / None disables per-job profile selection. Otherwise the profile name + is canonicalized with the same rules as ``hermes -p`` and must refer to an + existing profile at create/update time. ``default`` is the built-in root + profile and is always valid. + """ + if profile is None: + return None + raw = str(profile).strip() + if not raw: + return None + + from hermes_cli.profiles import normalize_profile_name, resolve_profile_env + + normalized = normalize_profile_name(raw) + # resolve_profile_env validates the canonical name and checks that named + # profiles exist. Store only the stable profile id, not the filesystem path, + # so profile directories can move with the Hermes root. + resolve_profile_env(normalized) + return normalized + + def create_job( prompt: Optional[str], schedule: str, @@ -495,6 +522,7 @@ def create_job( context_from: Optional[Union[str, List[str]]] = None, enabled_toolsets: Optional[List[str]] = None, workdir: Optional[str] = None, + profile: Optional[str] = None, no_agent: bool = False, ) -> Dict[str, Any]: """ @@ -536,6 +564,11 @@ def create_job( With ``no_agent=True``, ``workdir`` is still applied as the script's cwd so relative paths inside the script behave predictably. + profile: Optional Hermes profile name. When set, the job runs with + that profile's HERMES_HOME so profile-specific config, + credentials, scripts, skills, and memory paths resolve + consistently. ``default`` selects the root profile; empty / + None preserves the scheduler's existing behaviour. no_agent: When True, skip the agent entirely — run ``script`` on schedule and deliver its stdout directly. Empty stdout = silent (no delivery). Requires ``script`` to be set. Ideal for classic @@ -573,6 +606,7 @@ def create_job( normalized_toolsets = [str(t).strip() for t in enabled_toolsets if str(t).strip()] if enabled_toolsets else None normalized_toolsets = normalized_toolsets or None normalized_workdir = _normalize_workdir(workdir) + normalized_profile = _normalize_profile(profile) normalized_no_agent = bool(no_agent) # no_agent jobs are meaningless without a script — the script IS the job. @@ -627,6 +661,7 @@ def create_job( "origin": origin, # Tracks where job was created for "origin" delivery "enabled_toolsets": normalized_toolsets, "workdir": normalized_workdir, + "profile": normalized_profile, } jobs = load_jobs() @@ -707,6 +742,15 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]] else: updates["workdir"] = _normalize_workdir(_wd) + # Validate / normalize profile if present in updates. Empty string or + # None both mean "clear the field" (restore old behaviour). + if "profile" in updates: + _profile = updates["profile"] + if _profile is None or _profile == "" or _profile is False: + updates["profile"] = None + else: + updates["profile"] = _normalize_profile(_profile) + updated = _apply_skill_fields({**job, **updates}) schedule_changed = "schedule" in updates diff --git a/cron/scheduler.py b/cron/scheduler.py index 322fa64906fe..3468f33980b0 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -17,6 +17,7 @@ import shutil import subprocess import sys +from contextlib import contextmanager # fcntl is Unix-only; on Windows use msvcrt for file locking try: @@ -145,6 +146,49 @@ def _get_lock_paths() -> tuple[Path, Path]: return lock_dir, lock_dir / ".tick.lock" +@contextmanager +def _job_profile_context(job_id: str, profile: Optional[str]): + """Temporarily run a job under a specific Hermes profile. + + Cron jobs are stored and scheduled by the profile running the scheduler, but + an individual job can opt into a different runtime profile. While active, + HERMES_HOME and the scheduler's test/override hook both point at the + resolved profile directory so _get_hermes_home(), .env/config loading, script + resolution, AIAgent construction, and downstream get_hermes_home() callers + agree on the same home. + """ + raw_profile = str(profile or "").strip() + if not raw_profile: + yield None + return + + global _hermes_home + prior_env = os.environ.get("HERMES_HOME", "_UNSET_") + prior_override = _hermes_home + + from hermes_cli.profiles import normalize_profile_name, resolve_profile_env + + normalized_profile = normalize_profile_name(raw_profile) + profile_home = Path(resolve_profile_env(normalized_profile)).resolve() + + try: + os.environ["HERMES_HOME"] = str(profile_home) + _hermes_home = profile_home + logger.info( + "Job '%s': using Hermes profile '%s' (%s)", + job_id, + normalized_profile, + profile_home, + ) + yield normalized_profile + finally: + _hermes_home = prior_override + if prior_env == "_UNSET_": + os.environ.pop("HERMES_HOME", None) + else: + os.environ["HERMES_HOME"] = prior_env + + def _resolve_origin(job: dict) -> Optional[dict]: """Extract origin info from a job, preserving any extra routing metadata. @@ -1022,6 +1066,13 @@ def _scan_assembled_cron_prompt(assembled: str, job: dict) -> str: def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: + """Execute a single cron job, applying any per-job profile override.""" + job_id = job["id"] + with _job_profile_context(job_id, job.get("profile")): + return _run_job_impl(job) + + +def _run_job_impl(job: dict) -> tuple[bool, str, str, Optional[str]]: """ Execute a single cron job. @@ -1258,8 +1309,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: # .cursorrules from the job's project dir, AND # - the terminal, file, and code-exec tools run commands from there. # - # tick() serializes workdir-jobs outside the parallel pool, so mutating - # os.environ["TERMINAL_CWD"] here is safe for those jobs. For workdir-less + # tick() serializes jobs that mutate process-global runtime state (workdir + # and/or profile jobs) outside the parallel pool, so mutating + # os.environ["TERMINAL_CWD"] here is safe for those jobs. For workdir-less # jobs we leave TERMINAL_CWD untouched — preserves the original behaviour # (skip_context_files=True, tools use whatever cwd the scheduler has). _job_workdir = (job.get("workdir") or "").strip() or None @@ -1781,17 +1833,24 @@ def _process_job(job: dict) -> bool: mark_job_run(job["id"], False, str(e)) return False - # 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. - workdir_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()] + # Partition due jobs: jobs with a per-job workdir and/or profile mutate + # process-global runtime state inside run_job (TERMINAL_CWD, + # HERMES_HOME, and the scheduler's _hermes_home hook), so they MUST run + # sequentially to avoid corrupting each other. Jobs without either field + # leave those env overrides untouched and stay parallel-safe. + sequential_jobs = [ + j for j in due_jobs + if (j.get("workdir") or "").strip() or (j.get("profile") or "").strip() + ] + parallel_jobs = [ + j for j in due_jobs + if not ((j.get("workdir") or "").strip() or (j.get("profile") or "").strip()) + ] _results: list = [] - # Sequential pass for workdir jobs. - for job in workdir_jobs: + # Sequential pass for env-mutating jobs. + for job in sequential_jobs: _ctx = contextvars.copy_context() _results.append(_ctx.run(_process_job, job)) diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index 7bff9c6b87b5..2fc4a981a7ba 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -98,6 +98,9 @@ def cron_list(show_all: bool = False): workdir = job.get("workdir") if workdir: print(f" Workdir: {workdir}") + profile = job.get("profile") + if profile: + print(f" Profile: {profile}") # Execution history last_status = job.get("last_status") @@ -174,6 +177,7 @@ def cron_create(args): skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)), script=getattr(args, "script", None), workdir=getattr(args, "workdir", None), + profile=getattr(args, "profile", None), no_agent=getattr(args, "no_agent", False) or None, ) if not result.get("success"): @@ -191,6 +195,8 @@ def cron_create(args): print(" Mode: no-agent (script stdout delivered directly)") if job_data.get("workdir"): print(f" Workdir: {job_data['workdir']}") + if job_data.get("profile"): + print(f" Profile: {job_data['profile']}") print(f" Next run: {result['next_run_at']}") return 0 @@ -236,6 +242,7 @@ def cron_edit(args): skills=final_skills, script=getattr(args, "script", None), workdir=getattr(args, "workdir", None), + profile=getattr(args, "profile", None), no_agent=getattr(args, "no_agent", None), ) if not result.get("success"): @@ -256,6 +263,8 @@ def cron_edit(args): print(" Mode: no-agent (script stdout delivered directly)") if updated.get("workdir"): print(f" Workdir: {updated['workdir']}") + if updated.get("profile"): + print(f" Profile: {updated['profile']}") return 0 diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 48bf6675b32f..871ad681f533 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -10624,6 +10624,10 @@ def main(): "--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 name to run the job under. Use 'default' for the root profile. Named profiles must already exist. Omit to preserve the scheduler's existing profile.", + ) # cron edit cron_edit = cron_subparsers.add_parser( @@ -10688,6 +10692,10 @@ def main(): "--workdir", help="Absolute path for the job to run from (injects AGENTS.md etc. and sets terminal cwd). Pass empty string to clear.", ) + cron_edit.add_argument( + "--profile", + help="Hermes profile name to run the job under. Use 'default' for the root profile. Pass empty string to clear.", + ) # lifecycle actions cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job") diff --git a/tests/cron/test_cron_profile.py b/tests/cron/test_cron_profile.py new file mode 100644 index 000000000000..6041e3b76e09 --- /dev/null +++ b/tests/cron/test_cron_profile.py @@ -0,0 +1,340 @@ +"""Tests for per-job profile support in cron jobs. + +Covers data-layer validation/storage, cronjob tool plumbing, scheduler runtime +HERMES_HOME scoping, and tick() serialization for profile jobs. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + + +@pytest.fixture() +def isolated_cron_profile_home(tmp_path, monkeypatch): + """Create an isolated Hermes root with a named profile and temp cron store.""" + root = tmp_path / "hermes-root" + profile_home = root / "profiles" / "support" + profile_home.mkdir(parents=True) + (root / "cron").mkdir(parents=True) + + monkeypatch.setenv("HERMES_HOME", str(root)) + monkeypatch.setattr("cron.jobs.CRON_DIR", root / "cron") + monkeypatch.setattr("cron.jobs.JOBS_FILE", root / "cron" / "jobs.json") + monkeypatch.setattr("cron.jobs.OUTPUT_DIR", root / "cron" / "output") + + return root, profile_home + + +class TestNormalizeProfile: + def test_none_and_empty_return_none(self, isolated_cron_profile_home): + from cron.jobs import _normalize_profile + + assert _normalize_profile(None) is None + assert _normalize_profile("") is None + assert _normalize_profile(" ") is None + + def test_default_profile_is_valid_and_normalized(self, isolated_cron_profile_home): + from cron.jobs import _normalize_profile + + assert _normalize_profile("Default") == "default" + + def test_named_profile_must_exist_and_is_normalized(self, isolated_cron_profile_home): + from cron.jobs import _normalize_profile + + assert _normalize_profile("Support") == "support" + + def test_invalid_profile_name_is_rejected(self, isolated_cron_profile_home): + from cron.jobs import _normalize_profile + + with pytest.raises(ValueError): + _normalize_profile("invalid!") + + def test_missing_named_profile_is_rejected(self, isolated_cron_profile_home): + from cron.jobs import _normalize_profile + + with pytest.raises(FileNotFoundError): + _normalize_profile("missing") + + +class TestCreateAndUpdateJobProfile: + def test_create_stores_profile_id(self, isolated_cron_profile_home): + from cron.jobs import create_job, get_job + + job = create_job(prompt="hello", schedule="every 1h", profile="Support") + stored = get_job(job["id"]) + + assert stored is not None + assert stored["profile"] == "support" + + def test_create_without_profile_preserves_old_behaviour(self, isolated_cron_profile_home): + from cron.jobs import create_job, get_job + + job = create_job(prompt="hello", schedule="every 1h") + stored = get_job(job["id"]) + + assert stored is not None + assert stored.get("profile") is None + + def test_create_accepts_explicit_default(self, isolated_cron_profile_home): + from cron.jobs import create_job, get_job + + job = create_job(prompt="hello", schedule="every 1h", profile="default") + stored = get_job(job["id"]) + + assert stored is not None + assert stored["profile"] == "default" + + def test_update_sets_and_clears_profile(self, isolated_cron_profile_home): + from cron.jobs import create_job, get_job, update_job + + job = create_job(prompt="x", schedule="every 1h") + update_job(job["id"], {"profile": "Support"}) + stored = get_job(job["id"]) + assert stored is not None + assert stored["profile"] == "support" + + update_job(job["id"], {"profile": ""}) + stored = get_job(job["id"]) + assert stored is not None + assert stored["profile"] is None + + def test_update_rejects_missing_profile(self, isolated_cron_profile_home): + from cron.jobs import create_job, update_job + + job = create_job(prompt="x", schedule="every 1h") + with pytest.raises(FileNotFoundError): + update_job(job["id"], {"profile": "missing"}) + + +class TestCronjobToolProfile: + def test_create_and_list_with_profile(self, isolated_cron_profile_home): + from tools.cronjob_tools import cronjob + + created = json.loads( + cronjob( + action="create", + prompt="hi", + schedule="every 1h", + profile="Support", + ) + ) + assert created["success"] is True + assert created["job"]["profile"] == "support" + + listing = json.loads(cronjob(action="list")) + assert listing["jobs"][0]["profile"] == "support" + + def test_update_clears_profile_with_empty_string(self, isolated_cron_profile_home): + from tools.cronjob_tools import cronjob + + created = json.loads( + cronjob( + action="create", + prompt="hi", + schedule="every 1h", + profile="Support", + ) + ) + updated = json.loads( + cronjob(action="update", job_id=created["job_id"], profile="") + ) + + assert updated["success"] is True + assert "profile" not in updated["job"] + + def test_schema_advertises_profile(self): + from tools.cronjob_tools import CRONJOB_SCHEMA + + assert "profile" in CRONJOB_SCHEMA["parameters"]["properties"] + desc = CRONJOB_SCHEMA["parameters"]["properties"]["profile"]["description"] + assert "hermes profile" in desc.lower() + + +class TestRunJobProfileContext: + @staticmethod + def _install_agent_stubs(monkeypatch, observed: dict): + import sys + import cron.scheduler as sched + + class FakeAgent: + def __init__(self, **kwargs): + observed["hermes_home_during_init"] = os.environ.get("HERMES_HOME") + observed["scheduler_home_during_init"] = str(sched._get_hermes_home()) + observed["skip_context_files"] = kwargs.get("skip_context_files") + + def run_conversation(self, *_a, **_kw): + observed["hermes_home_during_run"] = os.environ.get("HERMES_HOME") + observed["scheduler_home_during_run"] = str(sched._get_hermes_home()) + return {"final_response": "done", "messages": []} + + def get_activity_summary(self): + return {"seconds_since_activity": 0.0} + + def close(self): + observed["closed"] = True + + fake_mod = type(sys)("run_agent") + fake_mod.AIAgent = FakeAgent + monkeypatch.setitem(sys.modules, "run_agent", fake_mod) + + from hermes_cli import runtime_provider as runtime_provider + + monkeypatch.setattr( + runtime_provider, + "resolve_runtime_provider", + lambda **_kw: { + "provider": "test", + "api_key": "test-key", + "base_url": "http://test.local", + "api_mode": "chat_completions", + }, + ) + + monkeypatch.setattr(sched, "_build_job_prompt", lambda job, prerun_script=None: "hi") + monkeypatch.setattr(sched, "_resolve_origin", lambda job: None) + monkeypatch.setattr(sched, "_resolve_delivery_target", lambda job: None) + monkeypatch.setattr(sched, "_resolve_cron_enabled_toolsets", lambda job, cfg: None) + monkeypatch.setattr(sched, "_hermes_home", None) + monkeypatch.setenv("HERMES_CRON_TIMEOUT", "0") + + import dotenv + + def fake_load_dotenv(path, *_a, **_kw): + observed.setdefault("dotenv_paths", []).append(str(path)) + return True + + monkeypatch.setattr(dotenv, "load_dotenv", fake_load_dotenv) + + def test_run_job_sets_and_restores_profile_home( + self, isolated_cron_profile_home, monkeypatch + ): + import cron.scheduler as sched + + root, profile_home = isolated_cron_profile_home + observed: dict = {} + self._install_agent_stubs(monkeypatch, observed) + + job = { + "id": "abc", + "name": "profile-job", + "profile": "support", + "schedule_display": "manual", + } + + success, _output, response, error = sched.run_job(job) + + assert success is True, f"run_job failed: error={error!r} response={response!r}" + assert observed["dotenv_paths"] == [str(profile_home / ".env")] + assert observed["hermes_home_during_init"] == str(profile_home.resolve()) + assert observed["hermes_home_during_run"] == str(profile_home.resolve()) + assert observed["scheduler_home_during_init"] == str(profile_home.resolve()) + assert observed["scheduler_home_during_run"] == str(profile_home.resolve()) + assert observed["skip_context_files"] is True + assert os.environ["HERMES_HOME"] == str(root) + assert sched._get_hermes_home() == root + + def test_no_agent_profile_uses_profile_scripts_dir_and_restores_env( + self, isolated_cron_profile_home, monkeypatch + ): + import cron.scheduler as sched + + root, profile_home = isolated_cron_profile_home + scripts_dir = profile_home / "scripts" + scripts_dir.mkdir(parents=True) + (scripts_dir / "print_home.py").write_text( + "import os\nprint(os.environ.get('HERMES_HOME', ''))\n", + encoding="utf-8", + ) + monkeypatch.setattr(sched, "_hermes_home", None) + + job = { + "id": "script1", + "name": "profile-script", + "profile": "support", + "script": "print_home.py", + "no_agent": True, + } + + success, _doc, response, error = sched.run_job(job) + + assert success is True, error + assert response.strip() == str(profile_home.resolve()) + assert os.environ["HERMES_HOME"] == str(root) + assert sched._get_hermes_home() == root + + def test_run_job_without_profile_leaves_hermes_home_untouched( + self, isolated_cron_profile_home, monkeypatch + ): + import cron.scheduler as sched + + root, _profile_home = isolated_cron_profile_home + observed: dict = {} + self._install_agent_stubs(monkeypatch, observed) + + job = { + "id": "noprof", + "name": "no-profile-job", + "profile": None, + "schedule_display": "manual", + } + + success, *_ = sched.run_job(job) + + assert success is True + assert observed["hermes_home_during_init"] == str(root) + assert os.environ["HERMES_HOME"] == str(root) + + def test_run_job_rejects_missing_runtime_profile( + self, isolated_cron_profile_home, monkeypatch + ): + import cron.scheduler as sched + + root, _profile_home = isolated_cron_profile_home + monkeypatch.setattr(sched, "_hermes_home", None) + + with pytest.raises(FileNotFoundError): + sched.run_job( + { + "id": "missing-profile", + "name": "missing-profile-job", + "profile": "missing", + } + ) + + assert os.environ["HERMES_HOME"] == str(root) + + +class TestTickProfilePartition: + def test_profile_jobs_run_sequentially(self, isolated_cron_profile_home, monkeypatch): + import threading + import cron.scheduler as sched + + profile_job = {"id": "a", "name": "A", "profile": "default"} + parallel_job = {"id": "b", "name": "B", "profile": None} + + monkeypatch.setattr(sched, "get_due_jobs", lambda: [profile_job, parallel_job]) + monkeypatch.setattr(sched, "advance_next_run", lambda *_a, **_kw: None) + + calls: list[tuple[str, str]] = [] + + def fake_run_job(job): + calls.append((job["id"], threading.current_thread().name)) + return True, "output", "response", None + + monkeypatch.setattr(sched, "run_job", fake_run_job) + monkeypatch.setattr(sched, "save_job_output", lambda _jid, _o: None) + monkeypatch.setattr(sched, "mark_job_run", lambda *_a, **_kw: None) + monkeypatch.setattr(sched, "_deliver_result", lambda *_a, **_kw: None) + + n = sched.tick(verbose=False) + + assert n == 2 + ids = [job_id for job_id, _thread_name in calls] + assert ids.index("a") < ids.index("b") + main_thread_name = threading.current_thread().name + profile_thread_name = next(thread for job_id, thread in calls if job_id == "a") + assert profile_thread_name == main_thread_name diff --git a/tests/hermes_cli/test_cron.py b/tests/hermes_cli/test_cron.py index 8593195a1bad..49628f1a438d 100644 --- a/tests/hermes_cli/test_cron.py +++ b/tests/hermes_cli/test_cron.py @@ -55,6 +55,7 @@ def test_edit_can_replace_and_clear_skills(self, tmp_cron_dir, capsys): repeat=None, skill=None, skills=["maps", "blogwatcher"], + profile="default", clear_skills=False, ) ) @@ -63,6 +64,7 @@ def test_edit_can_replace_and_clear_skills(self, tmp_cron_dir, capsys): assert updated["name"] == "Edited Job" assert updated["prompt"] == "Revised prompt" assert updated["schedule_display"] == "every 120m" + assert updated["profile"] == "default" cron_command( Namespace( @@ -75,12 +77,14 @@ def test_edit_can_replace_and_clear_skills(self, tmp_cron_dir, capsys): repeat=None, skill=None, skills=None, + profile="", clear_skills=True, ) ) cleared = get_job(job["id"]) assert cleared["skills"] == [] assert cleared["skill"] is None + assert cleared["profile"] is None out = capsys.readouterr().out assert "Updated job" in out @@ -96,6 +100,7 @@ def test_create_with_multiple_skills(self, tmp_cron_dir, capsys): repeat=None, skill=None, skills=["blogwatcher", "maps"], + profile="default", ) ) out = capsys.readouterr().out @@ -105,3 +110,4 @@ def test_create_with_multiple_skills(self, tmp_cron_dir, capsys): assert len(jobs) == 1 assert jobs[0]["skills"] == ["blogwatcher", "maps"] assert jobs[0]["name"] == "Skill combo" + assert jobs[0]["profile"] == "default" diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index a7a8a0feab97..5d91a6700d8e 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -281,6 +281,8 @@ def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: result["enabled_toolsets"] = job["enabled_toolsets"] if job.get("workdir"): result["workdir"] = job["workdir"] + if job.get("profile"): + result["profile"] = job["profile"] return result @@ -303,6 +305,7 @@ def cronjob( context_from: Optional[Union[str, List[str]]] = None, enabled_toolsets: Optional[List[str]] = None, workdir: Optional[str] = None, + profile: Optional[str] = None, no_agent: Optional[bool] = None, task_id: str = None, ) -> str: @@ -369,6 +372,7 @@ def cronjob( context_from=context_from, enabled_toolsets=enabled_toolsets or None, workdir=_normalize_optional_job_value(workdir), + profile=_normalize_optional_job_value(profile), no_agent=_no_agent, ) return json.dumps( @@ -503,6 +507,10 @@ def cronjob( # Empty string clears the field (restores old behaviour); # otherwise pass raw — update_job() validates / normalizes. updates["workdir"] = _normalize_optional_job_value(workdir) or None + if profile is not None: + # Empty string clears the field (restores old behaviour); + # otherwise pass raw — update_job() validates / normalizes. + updates["profile"] = _normalize_optional_job_value(profile) or None if no_agent is not None: # Toggling no_agent on/off at update time. If flipping to True, # we need a script to already exist on the job (or be part of @@ -656,6 +664,10 @@ def cronjob( "type": "string", "description": "Optional absolute path to run the job from. When set, AGENTS.md / CLAUDE.md / .cursorrules from that directory are injected into the system prompt, and the terminal/file/code_exec tools use it as their working directory — useful for running a job inside a specific project repo. Must be an absolute path that exists. When unset (default), preserves the original behaviour: no project context files, tools use the scheduler's cwd. On update, pass an empty string to clear. Jobs with workdir run sequentially (not parallel) to keep per-job directories isolated." }, + "profile": { + "type": "string", + "description": "Optional Hermes profile name to run the job under. When set, the scheduler resolves that profile and temporarily sets HERMES_HOME before loading .env/config.yaml and running the job. Use 'default' for the root Hermes profile. Named profiles must already exist. When unset (default), preserves the scheduler's existing profile. On update, pass an empty string to clear. Jobs with profile run sequentially (not parallel) to keep process-global profile state isolated." + }, }, "required": ["action"] } @@ -710,6 +722,7 @@ def check_cronjob_requirements() -> bool: context_from=args.get("context_from"), enabled_toolsets=args.get("enabled_toolsets"), workdir=args.get("workdir"), + profile=args.get("profile"), no_agent=args.get("no_agent"), task_id=kw.get("task_id"), ))(), From bf50a3a339099203f6411325f783e7c0b64a8e7e Mon Sep 17 00:00:00 2001 From: Gianfranco Piana <52470719+gianfrancopiana@users.noreply.github.com> Date: Thu, 14 May 2026 18:28:51 -0300 Subject: [PATCH 2/7] fix: avoid process-wide cron profile home mutation --- cron/scheduler.py | 34 ++++++---- hermes_constants.py | 34 +++++++++- tests/cron/test_cron_profile.py | 12 +++- tests/test_subprocess_home_isolation.py | 84 +++++++++++++++++++++++++ tools/environments/local.py | 16 +++++ 5 files changed, 165 insertions(+), 15 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 3468f33980b0..14d2a9bb7e8e 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -152,10 +152,11 @@ def _job_profile_context(job_id: str, profile: Optional[str]): Cron jobs are stored and scheduled by the profile running the scheduler, but an individual job can opt into a different runtime profile. While active, - HERMES_HOME and the scheduler's test/override hook both point at the - resolved profile directory so _get_hermes_home(), .env/config loading, script - resolution, AIAgent construction, and downstream get_hermes_home() callers - agree on the same home. + The scheduler's test/override hook and a context-local Hermes home override + both point at the resolved profile directory so _get_hermes_home(), + .env/config loading, script resolution, AIAgent construction, and downstream + get_hermes_home() callers agree on the same home without mutating the + process-wide environment seen by other threads. """ raw_profile = str(profile or "").strip() if not raw_profile: @@ -163,16 +164,17 @@ def _job_profile_context(job_id: str, profile: Optional[str]): return global _hermes_home - prior_env = os.environ.get("HERMES_HOME", "_UNSET_") prior_override = _hermes_home from hermes_cli.profiles import normalize_profile_name, resolve_profile_env + from hermes_constants import reset_hermes_home_override, set_hermes_home_override normalized_profile = normalize_profile_name(raw_profile) profile_home = Path(resolve_profile_env(normalized_profile)).resolve() + override_token = None try: - os.environ["HERMES_HOME"] = str(profile_home) + override_token = set_hermes_home_override(profile_home) _hermes_home = profile_home logger.info( "Job '%s': using Hermes profile '%s' (%s)", @@ -183,10 +185,8 @@ def _job_profile_context(job_id: str, profile: Optional[str]): yield normalized_profile finally: _hermes_home = prior_override - if prior_env == "_UNSET_": - os.environ.pop("HERMES_HOME", None) - else: - os.environ["HERMES_HOME"] = prior_env + if override_token is not None: + reset_hermes_home_override(override_token) def _resolve_origin(job: dict) -> Optional[dict]: @@ -776,8 +776,6 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: (success, output) — on failure *output* contains the error message so the LLM can report the problem to the user. """ - from hermes_constants import get_hermes_home - scripts_dir = _get_hermes_home() / "scripts" scripts_dir.mkdir(parents=True, exist_ok=True) scripts_dir_resolved = scripts_dir.resolve() @@ -829,6 +827,17 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: else: argv = [sys.executable, str(path)] + run_env = os.environ.copy() + run_env["HERMES_HOME"] = str(_get_hermes_home()) + try: + from hermes_constants import get_subprocess_home + + profile_home = get_subprocess_home() + if profile_home: + run_env["HOME"] = profile_home + except Exception: + pass + try: result = subprocess.run( argv, @@ -836,6 +845,7 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: text=True, timeout=script_timeout, cwd=str(path.parent), + env=run_env, ) stdout = (result.stdout or "").strip() stderr = (result.stderr or "").strip() diff --git a/hermes_constants.py b/hermes_constants.py index bdb8dc9114f8..13df867f5ca9 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -5,10 +5,38 @@ """ import os +from contextvars import ContextVar, Token from pathlib import Path _profile_fallback_warned: bool = False +_UNSET = object() +_HERMES_HOME_OVERRIDE: ContextVar[str | object] = ContextVar( + "_HERMES_HOME_OVERRIDE", default=_UNSET +) + + +def set_hermes_home_override(path: str | Path | None) -> Token: + """Set a context-local Hermes home override and return its reset token. + + This is for in-process, per-task scoping. It deliberately does not mutate + ``os.environ`` because that is shared by every thread in the process. + """ + value: str | object = _UNSET if path is None else str(path) + return _HERMES_HOME_OVERRIDE.set(value) + + +def reset_hermes_home_override(token: Token) -> None: + """Restore the previous context-local Hermes home override.""" + _HERMES_HOME_OVERRIDE.reset(token) + + +def get_hermes_home_override() -> str | None: + """Return the active context-local Hermes home override, if any.""" + override = _HERMES_HOME_OVERRIDE.get() + if override is _UNSET or not override: + return None + return str(override) def get_hermes_home() -> Path: @@ -27,6 +55,10 @@ def get_hermes_home() -> Path: template in ``hermes_cli/gateway.py`` and the kanban dispatcher in ``hermes_cli/kanban_db.py``). See https://github.com/NousResearch/hermes-agent/issues/18594. """ + override = get_hermes_home_override() + if override: + return Path(override) + val = os.environ.get("HERMES_HOME", "").strip() if val: return Path(val) @@ -179,7 +211,7 @@ def get_subprocess_home() -> str | None: Activation is directory-based: if the ``home/`` subdirectory doesn't exist, returns ``None`` and behavior is unchanged. """ - hermes_home = os.getenv("HERMES_HOME") + hermes_home = get_hermes_home_override() or os.getenv("HERMES_HOME") if not hermes_home: return None profile_home = os.path.join(hermes_home, "home") diff --git a/tests/cron/test_cron_profile.py b/tests/cron/test_cron_profile.py index 6041e3b76e09..de9b3b0d9ed8 100644 --- a/tests/cron/test_cron_profile.py +++ b/tests/cron/test_cron_profile.py @@ -162,12 +162,18 @@ def _install_agent_stubs(monkeypatch, observed: dict): class FakeAgent: def __init__(self, **kwargs): - observed["hermes_home_during_init"] = os.environ.get("HERMES_HOME") + from hermes_constants import get_hermes_home + + observed["env_home_during_init"] = os.environ.get("HERMES_HOME") + observed["hermes_home_during_init"] = str(get_hermes_home()) observed["scheduler_home_during_init"] = str(sched._get_hermes_home()) observed["skip_context_files"] = kwargs.get("skip_context_files") def run_conversation(self, *_a, **_kw): - observed["hermes_home_during_run"] = os.environ.get("HERMES_HOME") + from hermes_constants import get_hermes_home + + observed["env_home_during_run"] = os.environ.get("HERMES_HOME") + observed["hermes_home_during_run"] = str(get_hermes_home()) observed["scheduler_home_during_run"] = str(sched._get_hermes_home()) return {"final_response": "done", "messages": []} @@ -229,6 +235,8 @@ def test_run_job_sets_and_restores_profile_home( assert success is True, f"run_job failed: error={error!r} response={response!r}" assert observed["dotenv_paths"] == [str(profile_home / ".env")] + assert observed["env_home_during_init"] == str(root) + assert observed["env_home_during_run"] == str(root) assert observed["hermes_home_during_init"] == str(profile_home.resolve()) assert observed["hermes_home_during_run"] == str(profile_home.resolve()) assert observed["scheduler_home_during_init"] == str(profile_home.resolve()) diff --git a/tests/test_subprocess_home_isolation.py b/tests/test_subprocess_home_isolation.py index 2789d10b6da0..28401fa6644e 100644 --- a/tests/test_subprocess_home_isolation.py +++ b/tests/test_subprocess_home_isolation.py @@ -8,6 +8,7 @@ """ import os +import threading from pathlib import Path from unittest.mock import patch @@ -68,10 +69,50 @@ def test_two_profiles_get_different_homes(self, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(base / "beta")) home_b = get_subprocess_home() + assert home_a is not None + assert home_b is not None assert home_a != home_b assert home_a.endswith("alpha/home") assert home_b.endswith("beta/home") + def test_context_override_is_thread_local(self, tmp_path, monkeypatch): + root = tmp_path / "root" + profile = tmp_path / "profile" + root.mkdir() + profile.mkdir() + monkeypatch.setenv("HERMES_HOME", str(root)) + + from hermes_constants import ( + get_hermes_home, + reset_hermes_home_override, + set_hermes_home_override, + ) + + ready = threading.Event() + release = threading.Event() + seen: list[str] = [] + + def read_from_other_thread(): + ready.set() + release.wait(timeout=5) + seen.append(str(get_hermes_home())) + + thread = threading.Thread(target=read_from_other_thread) + thread.start() + assert ready.wait(timeout=5) + + token = set_hermes_home_override(profile) + try: + assert get_hermes_home() == profile + release.set() + thread.join(timeout=5) + finally: + reset_hermes_home_override(token) + release.set() + + assert seen == [str(root)] + assert get_hermes_home() == root + # --------------------------------------------------------------------------- # _make_run_env() injection @@ -116,6 +157,28 @@ def test_no_injection_when_hermes_home_unset(self, monkeypatch): assert result["HOME"] == "/home/user" + def test_context_override_bridges_to_subprocess_env(self, tmp_path, monkeypatch): + root = tmp_path / "root" + profile = tmp_path / "profile" + root.mkdir() + profile.mkdir() + (profile / "home").mkdir() + monkeypatch.setenv("HERMES_HOME", str(root)) + monkeypatch.setenv("HOME", "/root") + monkeypatch.setenv("PATH", "/usr/bin:/bin") + + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + from tools.environments.local import _make_run_env + + token = set_hermes_home_override(profile) + try: + result = _make_run_env({}) + finally: + reset_hermes_home_override(token) + + assert result["HERMES_HOME"] == str(profile) + assert result["HOME"] == str(profile / "home") + # --------------------------------------------------------------------------- # _sanitize_subprocess_env() injection @@ -147,6 +210,27 @@ def test_no_injection_when_home_dir_missing(self, tmp_path, monkeypatch): assert result["HOME"] == "/root" + def test_context_override_bridges_to_background_env(self, tmp_path, monkeypatch): + root = tmp_path / "root" + profile = tmp_path / "profile" + root.mkdir() + profile.mkdir() + (profile / "home").mkdir() + monkeypatch.setenv("HERMES_HOME", str(root)) + + base_env = {"HOME": "/root", "PATH": "/usr/bin"} + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + from tools.environments.local import _sanitize_subprocess_env + + token = set_hermes_home_override(profile) + try: + result = _sanitize_subprocess_env(base_env) + finally: + reset_hermes_home_override(token) + + assert result["HERMES_HOME"] == str(profile) + assert result["HOME"] == str(profile / "home") + # --------------------------------------------------------------------------- # Profile bootstrap diff --git a/tools/environments/local.py b/tools/environments/local.py index 177e5efab15d..9761aa14759f 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -170,6 +170,18 @@ def _build_provider_env_blocklist() -> frozenset: _HERMES_PROVIDER_ENV_BLOCKLIST = _build_provider_env_blocklist() +def _inject_context_hermes_home(env: dict) -> None: + """Bridge the context-local Hermes home override into subprocess env.""" + try: + from hermes_constants import get_hermes_home_override + + value = get_hermes_home_override() + if value: + env["HERMES_HOME"] = value + except Exception: + pass + + def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = None) -> dict: """Filter Hermes-managed secrets from a subprocess environment.""" try: @@ -192,6 +204,8 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non elif key not in _HERMES_PROVIDER_ENV_BLOCKLIST or _is_passthrough(key): sanitized[key] = value + _inject_context_hermes_home(sanitized) + # Per-profile HOME isolation for background processes (same as _make_run_env). from hermes_constants import get_subprocess_home _profile_home = get_subprocess_home() @@ -292,6 +306,8 @@ def _make_run_env(env: dict) -> dict: if not _IS_WINDOWS and "/usr/bin" not in existing_path.split(":"): run_env["PATH"] = f"{existing_path}:{_SANE_PATH}" if existing_path else _SANE_PATH + _inject_context_hermes_home(run_env) + # Per-profile HOME isolation: redirect system tool configs (git, ssh, gh, # npm …) into {HERMES_HOME}/home/ when that directory exists. Only the # subprocess sees the override — the Python process keeps the real HOME. From a5e6efde40070d909b75e25caa0ee5901be543b8 Mon Sep 17 00:00:00 2001 From: Gianfranco Piana <52470719+gianfrancopiana@users.noreply.github.com> Date: Mon, 18 May 2026 11:47:44 -0300 Subject: [PATCH 3/7] fix(cron): isolate profile job env --- cron/scheduler.py | 27 ++++++++++----- tests/cron/test_cron_profile.py | 60 ++++++++++++++++++++++++++++++++- tools/cronjob_tools.py | 2 +- 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 14d2a9bb7e8e..1e28711b16cc 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -151,12 +151,16 @@ def _job_profile_context(job_id: str, profile: Optional[str]): """Temporarily run a job under a specific Hermes profile. Cron jobs are stored and scheduled by the profile running the scheduler, but - an individual job can opt into a different runtime profile. While active, - The scheduler's test/override hook and a context-local Hermes home override + an individual job can opt into a different runtime profile. While active, + the scheduler's test/override hook and a context-local Hermes home override both point at the resolved profile directory so _get_hermes_home(), .env/config loading, script resolution, AIAgent construction, and downstream - get_hermes_home() callers agree on the same home without mutating the - process-wide environment seen by other threads. + get_hermes_home() callers agree on the same home. + + Some existing provider/config paths still load profile .env values through + os.environ, so profile jobs also snapshot and restore the process + environment on exit. tick() runs profile jobs sequentially to keep that + temporary mutation isolated from other scheduled jobs. """ raw_profile = str(profile or "").strip() if not raw_profile: @@ -165,6 +169,7 @@ def _job_profile_context(job_id: str, profile: Optional[str]): global _hermes_home prior_override = _hermes_home + env_snapshot = os.environ.copy() from hermes_cli.profiles import normalize_profile_name, resolve_profile_env from hermes_constants import reset_hermes_home_override, set_hermes_home_override @@ -187,6 +192,8 @@ def _job_profile_context(job_id: str, profile: Optional[str]): _hermes_home = prior_override if override_token is not None: reset_hermes_home_override(override_token) + os.environ.clear() + os.environ.update(env_snapshot) def _resolve_origin(job: dict) -> Optional[dict]: @@ -1843,11 +1850,13 @@ def _process_job(job: dict) -> bool: mark_job_run(job["id"], False, str(e)) return False - # Partition due jobs: jobs with a per-job workdir and/or profile mutate - # process-global runtime state inside run_job (TERMINAL_CWD, - # HERMES_HOME, and the scheduler's _hermes_home hook), so they MUST run + # Partition due jobs: jobs with a per-job workdir and/or profile touch + # process-global runtime state inside run_job. Workdir jobs temporarily + # set os.environ["TERMINAL_CWD"]; profile jobs use a context-local + # Hermes home override, scheduler _hermes_home hook, and temporary + # profile .env load into os.environ with snapshot/restore. They MUST run # sequentially to avoid corrupting each other. Jobs without either field - # leave those env overrides untouched and stay parallel-safe. + # stay parallel-safe. sequential_jobs = [ j for j in due_jobs if (j.get("workdir") or "").strip() or (j.get("profile") or "").strip() @@ -1859,7 +1868,7 @@ def _process_job(job: dict) -> bool: _results: list = [] - # Sequential pass for env-mutating jobs. + # Sequential pass for env/context-mutating jobs. for job in sequential_jobs: _ctx = contextvars.copy_context() _results.append(_ctx.run(_process_job, job)) diff --git a/tests/cron/test_cron_profile.py b/tests/cron/test_cron_profile.py index de9b3b0d9ed8..a8f438c185da 100644 --- a/tests/cron/test_cron_profile.py +++ b/tests/cron/test_cron_profile.py @@ -151,7 +151,11 @@ def test_schema_advertises_profile(self): assert "profile" in CRONJOB_SCHEMA["parameters"]["properties"] desc = CRONJOB_SCHEMA["parameters"]["properties"]["profile"]["description"] - assert "hermes profile" in desc.lower() + desc_lower = desc.lower() + assert "hermes profile" in desc_lower + assert "context-local" in desc_lower + assert "subprocess" in desc_lower + assert "temporarily sets hermes_home" not in desc_lower class TestRunJobProfileContext: @@ -165,6 +169,12 @@ def __init__(self, **kwargs): from hermes_constants import get_hermes_home observed["env_home_during_init"] = os.environ.get("HERMES_HOME") + observed["profile_env_only_during_init"] = os.environ.get( + "HERMES_PROFILE_TEST_ONLY" + ) + observed["profile_env_shared_during_init"] = os.environ.get( + "HERMES_PROFILE_TEST_SHARED" + ) observed["hermes_home_during_init"] = str(get_hermes_home()) observed["scheduler_home_during_init"] = str(sched._get_hermes_home()) observed["skip_context_files"] = kwargs.get("skip_context_files") @@ -173,6 +183,12 @@ def run_conversation(self, *_a, **_kw): from hermes_constants import get_hermes_home observed["env_home_during_run"] = os.environ.get("HERMES_HOME") + observed["profile_env_only_during_run"] = os.environ.get( + "HERMES_PROFILE_TEST_ONLY" + ) + observed["profile_env_shared_during_run"] = os.environ.get( + "HERMES_PROFILE_TEST_SHARED" + ) observed["hermes_home_during_run"] = str(get_hermes_home()) observed["scheduler_home_during_run"] = str(sched._get_hermes_home()) return {"final_response": "done", "messages": []} @@ -245,6 +261,48 @@ def test_run_job_sets_and_restores_profile_home( assert os.environ["HERMES_HOME"] == str(root) assert sched._get_hermes_home() == root + def test_profile_dotenv_environment_is_restored( + self, isolated_cron_profile_home, monkeypatch + ): + import dotenv + import cron.scheduler as sched + + root, profile_home = isolated_cron_profile_home + observed: dict = {} + self._install_agent_stubs(monkeypatch, observed) + monkeypatch.setenv("HERMES_PROFILE_TEST_SHARED", "outer") + monkeypatch.delenv("HERMES_PROFILE_TEST_ONLY", raising=False) + + def fake_load_dotenv(path, *_a, **_kw): + observed.setdefault("dotenv_paths", []).append(str(path)) + os.environ["HERMES_PROFILE_TEST_SHARED"] = "profile-value" + os.environ["HERMES_PROFILE_TEST_ONLY"] = "profile-only" + os.environ["HERMES_CRON_TIMEOUT"] = "123" + return True + + monkeypatch.setattr(dotenv, "load_dotenv", fake_load_dotenv) + + job = { + "id": "env-profile", + "name": "profile-env-job", + "profile": "support", + "schedule_display": "manual", + } + + success, _output, _response, error = sched.run_job(job) + + assert success is True, error + assert observed["dotenv_paths"] == [str(profile_home / ".env")] + assert observed["profile_env_only_during_init"] == "profile-only" + assert observed["profile_env_shared_during_init"] == "profile-value" + assert observed["profile_env_only_during_run"] == "profile-only" + assert observed["profile_env_shared_during_run"] == "profile-value" + assert os.environ["HERMES_PROFILE_TEST_SHARED"] == "outer" + assert "HERMES_PROFILE_TEST_ONLY" not in os.environ + assert os.environ["HERMES_CRON_TIMEOUT"] == "0" + assert os.environ["HERMES_HOME"] == str(root) + assert sched._get_hermes_home() == root + def test_no_agent_profile_uses_profile_scripts_dir_and_restores_env( self, isolated_cron_profile_home, monkeypatch ): diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 5d91a6700d8e..ea5df132712a 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -666,7 +666,7 @@ def cronjob( }, "profile": { "type": "string", - "description": "Optional Hermes profile name to run the job under. When set, the scheduler resolves that profile and temporarily sets HERMES_HOME before loading .env/config.yaml and running the job. Use 'default' for the root Hermes profile. Named profiles must already exist. When unset (default), preserves the scheduler's existing profile. On update, pass an empty string to clear. Jobs with profile run sequentially (not parallel) to keep process-global profile state isolated." + "description": "Optional Hermes profile name to run the job under. When set, the scheduler resolves that profile, applies a context-local Hermes home override, loads that profile's config/.env for the run, and bridges HERMES_HOME into subprocesses. Any temporary process-environment changes from profile .env loading are restored after the job exits. Use 'default' for the root Hermes profile. Named profiles must already exist. When unset (default), preserves the scheduler's existing profile. On update, pass an empty string to clear. Jobs with profile run sequentially (not parallel) to keep profile-scoped runtime state isolated." }, }, "required": ["action"] From 20ad2f630f013a982bba46b597c7ba2647c06141 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 18 May 2026 17:29:26 +0000 Subject: [PATCH 4/7] chore: add gianfrancopiana to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 5f0f66c17676..9b70d836b1f2 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -104,6 +104,7 @@ "hugosequier@gmail.com": "Hugo-SEQUIER", "128259593+Gutslabs@users.noreply.github.com": "Gutslabs", "50326054+nocturnum91@users.noreply.github.com": "nocturnum91", + "52470719+gianfrancopiana@users.noreply.github.com": "gianfrancopiana", "223003280+Abd0r@users.noreply.github.com": "Abd0r", "HuangYuChuh@users.noreply.github.com": "HuangYuChuh", "aaronwong1989@gmail.com": "hrygo", From 4be0f2ed9c5e683d37aa5453aa49f264f7061a88 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 18 May 2026 17:29:51 +0000 Subject: [PATCH 5/7] fix(cron): use delta-based env restore instead of clear+update Avoids a brief window where other threads see an empty os.environ during profile job teardown. Idea from PR #19958. --- cron/scheduler.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 1e28711b16cc..7132213eaa07 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -192,8 +192,14 @@ def _job_profile_context(job_id: str, profile: Optional[str]): _hermes_home = prior_override if override_token is not None: reset_hermes_home_override(override_token) - os.environ.clear() - os.environ.update(env_snapshot) + # Delta-based restore: remove added keys, restore changed keys. + # Avoids a brief window where other threads see an empty env. + added = set(os.environ.keys()) - set(env_snapshot.keys()) + for k in added: + os.environ.pop(k, None) + for k, v in env_snapshot.items(): + if os.environ.get(k) != v: + os.environ[k] = v def _resolve_origin(job: dict) -> Optional[dict]: From 83a042e59d3c48343b2c7835a0e67b988f496ba5 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 18 May 2026 17:30:36 +0000 Subject: [PATCH 6/7] fix(cron): gracefully degrade when runtime profile is deleted Instead of raising FileNotFoundError (which silently bricks the job), log a warning and fall back to the scheduler default home. Validates at create/update time still catches typos. Idea from PR #19958. --- cron/scheduler.py | 11 ++++++++++- tests/cron/test_cron_profile.py | 25 +++++++++++++++---------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 7132213eaa07..9a1f3d1bfe59 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -175,7 +175,16 @@ def _job_profile_context(job_id: str, profile: Optional[str]): from hermes_constants import reset_hermes_home_override, set_hermes_home_override normalized_profile = normalize_profile_name(raw_profile) - profile_home = Path(resolve_profile_env(normalized_profile)).resolve() + try: + profile_home = Path(resolve_profile_env(normalized_profile)).resolve() + except (FileNotFoundError, ValueError) as exc: + logger.warning( + "Job '%s': configured profile %r no longer valid (%s) — " + "falling back to scheduler default", + job_id, raw_profile, exc, + ) + yield None + return override_token = None try: diff --git a/tests/cron/test_cron_profile.py b/tests/cron/test_cron_profile.py index a8f438c185da..8e8d3f7ca1a7 100644 --- a/tests/cron/test_cron_profile.py +++ b/tests/cron/test_cron_profile.py @@ -354,23 +354,28 @@ def test_run_job_without_profile_leaves_hermes_home_untouched( assert observed["hermes_home_during_init"] == str(root) assert os.environ["HERMES_HOME"] == str(root) - def test_run_job_rejects_missing_runtime_profile( + def test_run_job_falls_back_on_missing_runtime_profile( self, isolated_cron_profile_home, monkeypatch ): import cron.scheduler as sched root, _profile_home = isolated_cron_profile_home - monkeypatch.setattr(sched, "_hermes_home", None) + observed: dict = {} + self._install_agent_stubs(monkeypatch, observed) - with pytest.raises(FileNotFoundError): - sched.run_job( - { - "id": "missing-profile", - "name": "missing-profile-job", - "profile": "missing", - } - ) + job = { + "id": "missing-profile", + "name": "missing-profile-job", + "profile": "missing", + "schedule_display": "manual", + } + # Should succeed with fallback, not raise + success, _output, response, error = sched.run_job(job) + + assert success is True, f"run_job should fallback, not fail: error={error!r}" + # Verify it used the default home, not the missing profile + assert observed["hermes_home_during_init"] == str(root) assert os.environ["HERMES_HOME"] == str(root) From 6b9a64e22000e2cd6f068d68f0b75cfe7f5b958e Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 18 May 2026 17:31:02 +0000 Subject: [PATCH 7/7] test(cron): cover profile + workdir combined scenario --- tests/cron/test_cron_profile.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/cron/test_cron_profile.py b/tests/cron/test_cron_profile.py index 8e8d3f7ca1a7..887849e635f0 100644 --- a/tests/cron/test_cron_profile.py +++ b/tests/cron/test_cron_profile.py @@ -380,6 +380,33 @@ def test_run_job_falls_back_on_missing_runtime_profile( class TestTickProfilePartition: + def test_profile_and_workdir_combined(self, isolated_cron_profile_home, monkeypatch): + """Both profile and workdir set — verify both are applied and restored.""" + import cron.scheduler as sched + + root, profile_home = isolated_cron_profile_home + observed: dict = {} + TestRunJobProfileContext._install_agent_stubs(monkeypatch, observed) + fake_workdir = str(root / "myproject") + (root / "myproject").mkdir() + + job = { + "id": "combo", + "name": "combo-job", + "profile": "support", + "workdir": fake_workdir, + "schedule_display": "manual", + } + + success, _output, _response, error = sched.run_job(job) + + assert success is True, error + assert observed["hermes_home_during_init"] == str(profile_home.resolve()) + assert os.environ.get("TERMINAL_CWD", "") != fake_workdir, \ + "TERMINAL_CWD should be restored after job" + assert os.environ["HERMES_HOME"] == str(root) + assert sched._get_hermes_home() == root + def test_profile_jobs_run_sequentially(self, isolated_cron_profile_home, monkeypatch): import threading import cron.scheduler as sched