revert(cron): remove per-job profile support (PR #28124) (#43956)

Fully removes the cron per-job 'profile' arg added in #28124: the
cronjob tool schema field, CLI --profile flags on cron create/edit,
job-record storage/validation, the scheduler's _job_profile_context
wrapper, and the script-runner env override. Sequential-partition
logic reverts to workdir-only.

The context-local HERMES_HOME override in hermes_constants and the
subprocess bridging in tools/environments/local.py are kept — they
now have other consumers (dashboard multi-profile, TUI gateway).
This commit is contained in:
Teknium 2026-06-10 20:46:17 -07:00 committed by GitHub
parent 68ffedb6a9
commit 7d8d000b19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 19 additions and 672 deletions

View file

@ -150,9 +150,6 @@ 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
@ -523,30 +520,6 @@ 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,
@ -563,7 +536,6 @@ 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]:
"""
@ -605,11 +577,6 @@ 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
@ -647,7 +614,6 @@ 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.
@ -702,7 +668,6 @@ 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()
@ -792,15 +757,6 @@ 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

View file

@ -19,7 +19,6 @@ import shutil
import subprocess
import sys
import threading
from contextlib import contextmanager
# fcntl is Unix-only; on Windows use msvcrt for file locking
try:
@ -166,7 +165,7 @@ _parallel_pool_max_workers: Optional[int] = None
_running_job_ids: set = set()
_running_lock = threading.Lock()
# Sequential (env/context-mutating) cron jobs — workdir/profile jobs that touch
# Sequential (env-mutating) cron jobs — workdir jobs that touch
# process-global runtime state — must run one at a time, but must NOT block the
# ticker thread. A persistent single-thread executor preserves ordering across
# ticks while keeping dispatch fire-and-forget, the same as the parallel pool.
@ -190,10 +189,10 @@ def _get_parallel_pool(max_workers: Optional[int]) -> concurrent.futures.ThreadP
def _get_sequential_pool() -> concurrent.futures.ThreadPoolExecutor:
"""Return (or create) the persistent single-thread sequential pool.
A single worker guarantees env/context-mutating jobs never overlap, even
A single worker guarantees env-mutating jobs never overlap, even
across ticks: a job queued by a newer tick waits for the previous tick's
sequential jobs to finish rather than corrupting their os.environ /
profile state.
sequential jobs to finish rather than corrupting their os.environ
state.
"""
global _sequential_pool
if _sequential_pool is None:
@ -235,71 +234,6 @@ 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,
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.
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:
yield None
return
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
normalized_profile = normalize_profile_name(raw_profile)
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:
override_token = set_hermes_home_override(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 override_token is not None:
reset_hermes_home_override(override_token)
# 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]:
"""Extract origin info from a job, preserving any extra routing metadata.
@ -1032,17 +966,6 @@ 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:
popen_kwargs = {"creationflags": windows_hide_flags()} if sys.platform == "win32" else {}
result = subprocess.run(
@ -1051,7 +974,6 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
text=True,
timeout=script_timeout,
cwd=str(path.parent),
env=run_env,
**popen_kwargs,
)
stdout = (result.stdout or "").strip()
@ -1381,13 +1303,6 @@ def _scan_assembled_cron_prompt(
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.
@ -1624,9 +1539,8 @@ def _run_job_impl(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 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
# tick() serializes workdir-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
@ -2173,21 +2087,12 @@ def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> i
mark_job_run(job["id"], False, str(e))
return False
# 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
# 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())
]
# 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 = []
@ -2216,9 +2121,9 @@ def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> i
return pool.submit(_run_and_release)
# Sequential pass for env/context-mutating (workdir/profile) jobs.
# Sequential pass for env-mutating (workdir) jobs.
# Queued to a persistent single-thread pool so they run one at a time
# WITHOUT blocking the ticker thread — a long workdir/profile job no
# WITHOUT blocking the ticker thread — a long workdir job no
# longer starves the rest of the schedule (same fix as the parallel
# pass, just serialized). The in-flight guard prevents a still-running
# job from being re-queued on the next tick.

View file

@ -120,9 +120,6 @@ 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")
@ -221,7 +218,6 @@ 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"):
@ -239,8 +235,6 @@ 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
@ -286,7 +280,6 @@ 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"):
@ -307,8 +300,6 @@ 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

View file

@ -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 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(
@ -138,10 +134,6 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None:
"--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")

View file

@ -1,449 +0,0 @@
"""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
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"]
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:
@staticmethod
def _install_agent_stubs(monkeypatch, observed: dict):
import sys
import cron.scheduler as sched
class FakeAgent:
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")
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": []}
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["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())
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_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
):
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_falls_back_on_missing_runtime_profile(
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": "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)
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
# Two profile jobs (both sequential) + one parallel job.
profile_a = {"id": "a", "name": "A", "profile": "default"}
profile_b = {"id": "b", "name": "B", "profile": "default"}
parallel_job = {"id": "c", "name": "C", "profile": None}
monkeypatch.setattr(sched, "get_due_jobs", lambda: [profile_a, profile_b, parallel_job])
monkeypatch.setattr(sched, "advance_next_run", lambda *_a, **_kw: None)
calls: list[tuple[str, str]] = []
order_lock = threading.Lock()
def fake_run_job(job):
with order_lock:
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 == 3
ids = [job_id for job_id, _thread_name in calls]
# Sequential profile jobs preserve submission order relative to each
# other (single-thread pool).
assert ids.index("a") < ids.index("b")
# Sequential (profile) jobs run on the persistent single-thread
# cron-seq pool — NOT the main thread — so a long profile job never
# blocks the ticker. Parallel jobs run on the cron-parallel pool.
for jid in ("a", "b"):
seq_thread = next(t for job_id, t in calls if job_id == jid)
assert seq_thread != threading.current_thread().name
assert seq_thread.startswith("cron-seq"), seq_thread
par_thread = next(t for job_id, t in calls if job_id == "c")
assert par_thread.startswith("cron-parallel"), par_thread

View file

@ -172,10 +172,10 @@ class TestSyncMode:
class TestSequentialPool:
"""Sequential (workdir/profile) jobs use the persistent cron-seq pool.
"""Sequential (workdir) jobs use the persistent cron-seq pool.
Verifies the follow-up fix: env/context-mutating jobs no longer run inline
in the ticker thread, so a long workdir/profile job can't starve the
Verifies the follow-up fix: env-mutating jobs no longer run inline
in the ticker thread, so a long workdir job can't starve the
schedule the same way the parallel path used to.
"""

View file

@ -1487,7 +1487,7 @@ class TestRunJobConfigLogging:
}
# Mock heavy post-yaml work so the test only exercises the warning
# path. Without these mocks, _run_job_impl continues into provider
# path. Without these mocks, run_job continues into provider
# resolution and MCP discovery, both of which can spawn subprocesses
# / hit the network and have caused this test to time out on CI
# (>30s wall clock) under load. See PR #33661 follow-up.

View file

@ -55,7 +55,6 @@ class TestCronCommandLifecycle:
repeat=None,
skill=None,
skills=["maps", "blogwatcher"],
profile="default",
clear_skills=False,
)
)
@ -64,7 +63,6 @@ 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(
@ -77,14 +75,12 @@ 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
@ -100,7 +96,6 @@ class TestCronCommandLifecycle:
repeat=None,
skill=None,
skills=["blogwatcher", "maps"],
profile="default",
)
)
out = capsys.readouterr().out
@ -110,7 +105,6 @@ class TestCronCommandLifecycle:
assert len(jobs) == 1
assert jobs[0]["skills"] == ["blogwatcher", "maps"]
assert jobs[0]["name"] == "Skill combo"
assert jobs[0]["profile"] == "default"
def test_list_does_not_crash_when_repeat_is_null(self, tmp_cron_dir, capsys):
"""A one-shot job can be persisted with ``"repeat": null``. `cron

View file

@ -459,8 +459,6 @@ 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
@ -483,7 +481,6 @@ 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:
@ -550,7 +547,6 @@ 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(
@ -685,10 +681,6 @@ 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
@ -842,10 +834,6 @@ 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, 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"]
}
@ -900,7 +888,6 @@ 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"),
))(),

View file

@ -125,35 +125,6 @@ When `workdir` is set:
Jobs with a `workdir` run sequentially on the scheduler tick, not in the parallel pool. This is deliberate: the cron worker applies the job workdir through process-global terminal state, so two workdir jobs running at the same time would corrupt each other's cwd. Workdir-less jobs still run in parallel as before.
:::
## Running cron jobs in a specific profile
By default a cron job inherits whichever Hermes profile owned the gateway / CLI that created it. Pass `--profile <name>` (CLI) or `profile=` (cronjob tool) to re-target the job at a different profile — the scheduler resolves that profile's `HERMES_HOME`, temporarily switches into it for the duration of the run, loads its `.env` + `config.yaml`, and executes the job there:
```bash
# Pin a job to the `night-ops` profile regardless of where it was scheduled
hermes cron create "every 1d at 03:00" \
"Tail the security log and flag anomalies" \
--profile night-ops
```
```python
# From a chat, via the cronjob tool
cronjob(
action="create",
schedule="every 1d at 03:00",
prompt="Tail the security log and flag anomalies",
profile="night-ops",
)
```
Use `--profile default` to explicitly pin to the root Hermes profile. The named profile must already exist; the scheduler refuses to create profiles on the fly. To clear a profile pin during `cron edit`, pass an empty string (`--profile ""` or `profile=""`) — the job reverts to running in whatever profile the scheduler itself is in.
If the pinned profile is later deleted, the scheduler logs a warning and falls back to running the job in its current profile rather than crashing — so a stale `profile` reference never wedges a job.
:::note Serialization
Jobs with a `profile` set also run sequentially, for the same reason as `workdir`-pinned jobs: switching `HERMES_HOME` is a process-global mutation, so two profile-pinned jobs running in parallel would race each other. Unpinned jobs still run in the normal parallel pool.
:::
## Editing jobs
You do not need to delete and recreate jobs just to change them.
@ -223,7 +194,7 @@ What they do:
- `resume` — re-enable the job and compute the next future run
- `run` — trigger the job on the next scheduler tick
- `remove` — delete it entirely
- `edit` — modify schedule, prompt, profile, delivery, etc.
- `edit` — modify schedule, prompt, delivery, etc.
**Name-based lookup.** All four mutating verbs (`pause`, `resume`, `run`, `remove`, `edit`) plus the agent's `cronjob` tool now accept a job **name** (case-insensitive) in place of the hex ID. The agent and CLI both prefer an exact ID match if one exists; ambiguous name matches (multiple jobs sharing the same name) are refused with the full list of candidate IDs so you can pick one explicitly. Names are not unique, so this guard is load-bearing — it prevents silently mutating the wrong job when two share a name.