Merge remote-tracking branch 'origin/main' into hermes/hermes-6b48295e
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+14
-109
@@ -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:
|
||||
@@ -167,7 +166,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.
|
||||
@@ -191,10 +190,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:
|
||||
@@ -236,71 +235,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.
|
||||
|
||||
@@ -1033,17 +967,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(
|
||||
@@ -1052,7 +975,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()
|
||||
@@ -1382,13 +1304,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.
|
||||
|
||||
@@ -1625,9 +1540,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
|
||||
@@ -2174,21 +2088,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 = []
|
||||
@@ -2217,9 +2122,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.
|
||||
|
||||
Reference in New Issue
Block a user