feat: add cron job profile support
This commit is contained in:
committed by
daimon-nous[bot]
parent
47bc8e080d
commit
bb9ecb2178
44
cron/jobs.py
44
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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user