feat: add cron job profile support

This commit is contained in:
Gianfranco Piana
2026-05-14 16:42:39 -03:00
committed by daimon-nous[bot]
parent 47bc8e080d
commit bb9ecb2178
7 changed files with 489 additions and 10 deletions

View File

@@ -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

View File

@@ -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))