Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions cron/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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,
Expand All @@ -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]:
"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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

Expand Down
117 changes: 105 additions & 12 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -145,6 +146,71 @@ 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.

Expand Down Expand Up @@ -732,8 +798,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()
Expand Down Expand Up @@ -785,13 +849,25 @@ 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,
capture_output=True,
text=True,
timeout=script_timeout,
cwd=str(path.parent),
env=run_env,
)
stdout = (result.stdout or "").strip()
stderr = (result.stderr or "").strip()
Expand Down Expand Up @@ -1022,6 +1098,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.

Expand Down Expand Up @@ -1258,8 +1341,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
Expand Down Expand Up @@ -1781,17 +1865,26 @@ 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 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())
]

_results: list = []

# Sequential pass for workdir jobs.
for job in workdir_jobs:
# Sequential pass for env/context-mutating jobs.
for job in sequential_jobs:
_ctx = contextvars.copy_context()
_results.append(_ctx.run(_process_job, job))

Expand Down
9 changes: 9 additions & 0 deletions hermes_cli/cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"):
Expand All @@ -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

Expand Down Expand Up @@ -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"):
Expand All @@ -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


Expand Down
8 changes: 8 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand Down
34 changes: 33 additions & 1 deletion hermes_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading