diff --git a/cron/jobs.py b/cron/jobs.py index c5da32d44d5..6d7845c496c 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 322fa64906f..3468f33980b 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -17,6 +17,7 @@ import os 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 tick(verbose: bool = True, adapters=None, loop=None) -> int: 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 7bff9c6b87b..2fc4a981a7b 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 48bf6675b32..871ad681f53 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 00000000000..6041e3b76e0 --- /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 8593195a1ba..49628f1a438 100644 --- a/tests/hermes_cli/test_cron.py +++ b/tests/hermes_cli/test_cron.py @@ -55,6 +55,7 @@ class TestCronCommandLifecycle: repeat=None, skill=None, skills=["maps", "blogwatcher"], + profile="default", clear_skills=False, ) ) @@ -63,6 +64,7 @@ class TestCronCommandLifecycle: 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 @@ class TestCronCommandLifecycle: 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 @@ class TestCronCommandLifecycle: repeat=None, skill=None, skills=["blogwatcher", "maps"], + profile="default", ) ) out = capsys.readouterr().out @@ -105,3 +110,4 @@ class TestCronCommandLifecycle: 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 a7a8a0feab9..5d91a6700d8 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 @@ Important safety rule: cron-run sessions should not recursively schedule more cr "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 @@ registry.register( 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"), ))(),