""" Cron job storage and management. Jobs are stored in ~/.hermes/cron/jobs.json Output is saved to ~/.hermes/cron/output/{job_id}/{timestamp}.md """ import contextlib import copy from contextvars import ContextVar from dataclasses import dataclass import json import logging import shutil import tempfile import threading import time import os import re import uuid # Cross-process advisory file locking for jobs.json critical sections. # fcntl is Unix-only; on Windows fall back to msvcrt. Either may be absent, # in which case _jobs_lock() degrades to in-process locking only (the old # behaviour) rather than failing. try: import fcntl except ImportError: # pragma: no cover - non-Unix fcntl = None try: import msvcrt except ImportError: # pragma: no cover - non-Windows msvcrt = None from datetime import datetime, timedelta from pathlib import Path from hermes_constants import get_hermes_home from typing import Optional, Dict, List, Any, Set, Tuple, Union, Collection logger = logging.getLogger(__name__) from hermes_time import now as _hermes_now from utils import atomic_replace, atomic_write_text # ``croniter`` compiles ~15 ms of regexes at import and only matters for # 5-field cron expressions. Resolve lazily; ``HAS_CRONITER`` stays a module # attribute (tests monkeypatch it, and a monkeypatched value wins because # ``_ensure_croniter`` only probes while it's still None). croniter = None HAS_CRONITER: Optional[bool] = None def _ensure_croniter() -> bool: """Import croniter on first use; honor a pre-set HAS_CRONITER override.""" global croniter, HAS_CRONITER if HAS_CRONITER is None: try: from croniter import croniter as _croniter croniter = _croniter HAS_CRONITER = True except ImportError: HAS_CRONITER = False return bool(HAS_CRONITER) # ============================================================================= # Configuration # ============================================================================= # Cron is per-profile by design (issue #4707). Each profile owns its own cron # store under its own HERMES_HOME, and a profile-scoped gateway runs that # profile's jobs under that same HERMES_HOME — so a job authored in profile # `coder` lives in `~/.hermes/profiles/coder/cron/jobs.json` and executes with # `coder`'s `.env`, `config.yaml`, and skills. We deliberately anchor on # `get_hermes_home()` (the active profile home), NOT `get_default_hermes_root()` # (the shared root). Anchoring at the root would funnel every profile's jobs # into one shared `jobs.json` and run them under whatever HERMES_HOME the # ticker process happens to have — leaking config/credentials/skills across # profiles (the security boundary #4707 was filed for). Do NOT change this to # the default root: that re-breaks per-profile isolation. See also the dynamic # `_get_hermes_home()` / `_get_lock_paths()` resolution in cron/scheduler.py. HERMES_DIR = get_hermes_home().resolve() # These constants remain the default-profile fallback and a compatibility # surface for existing callers/tests. Cross-profile callers must scope paths # with use_cron_store() instead of mutating them process-wide. CRON_DIR = HERMES_DIR / "cron" JOBS_FILE = CRON_DIR / "jobs.json" # Heartbeat file the in-process ticker touches on every loop iteration. The # gateway process and the (separate) ``hermes cron status`` process share it # so status can tell whether the ticker THREAD is alive, not just whether the # gateway PROCESS exists — a ticker that dies silently inside a live gateway # would otherwise report healthy (#32612, #32895). TICKER_HEARTBEAT_FILE = CRON_DIR / "ticker_heartbeat" # Last tick that completed WITHOUT raising. Distinguishing this from the plain # heartbeat lets status detect a ticker that is alive but failing every tick. TICKER_SUCCESS_FILE = CRON_DIR / "ticker_last_success" # Default ticker loop interval (seconds). The single source of truth shared by # the in-process ticker (cron/scheduler_provider.py) and the staleness # threshold in `hermes cron status` (hermes_cli/cron.py), so the two never # drift apart. TICKER_INTERVAL_SECONDS = 60 # In-process lock protecting load_jobs→modify→save_jobs cycles. # Required when tick() runs jobs in parallel threads — without this, # concurrent mark_job_run / advance_next_run calls can clobber each other. _jobs_file_lock = threading.RLock() _jobs_lock_state = threading.local() _fire_fence_locks: Dict[str, threading.RLock] = {} _fire_fence_locks_guard = threading.Lock() _fire_fence_lock_state = threading.local() # Upper bound on waiting for the cross-process .jobs.lock flock (#60703). # Every cron function in the process funnels through _jobs_lock(), and the # flock is taken while holding the process-wide RLock — so an unbounded wait # on a lock held by a wedged sibling process silently freezes the ticker # heartbeat and every job forever. 30s is orders of magnitude above any # legitimate critical section (field updates only) while keeping the ticker's # worst-case stall well under one status-alarm threshold. _JOBS_LOCK_TIMEOUT_SECONDS = 30.0 OUTPUT_DIR = CRON_DIR / "output" ONESHOT_GRACE_SECONDS = 120 @dataclass(frozen=True) class _CronStorePaths: cron_dir: Path jobs_file: Path output_dir: Path _cron_store_override: ContextVar[Optional[_CronStorePaths]] = ContextVar( "cron_store_override", default=None, ) # Import-time snapshot of the compatibility constants, so deliberate # re-pointing of the module surface (monkeypatched CRON_DIR/JOBS_FILE/ # OUTPUT_DIR — the documented escape hatch existing tests/embedders use) # is distinguishable from the constants merely being stale. _IMPORT_STORE = _CronStorePaths(CRON_DIR, JOBS_FILE, OUTPUT_DIR) def _current_cron_store() -> _CronStorePaths: """Return paths pinned to this execution context's profile. Precedence, most explicit first: 1. an active use_cron_store() override (ContextVar); 2. deliberately re-pointed module constants — if CRON_DIR/JOBS_FILE/ OUTPUT_DIR no longer match their import-time values, someone chose the documented process-wide compatibility surface; honor it; 3. the ACTIVE profile home, resolved fresh via get_hermes_home() (context-local override, then the HERMES_HOME env var) — so a test or embedder that re-points HERMES_HOME after this module was imported reads/writes ITS OWN store, not whatever jobs.json the import happened to freeze (the filed incident: fixtures that patched the env too late silently rewrote the user's real jobs file); 4. the import-time constants (home unchanged since import — the common path, returned unchanged). """ override = _cron_store_override.get() if override is not None: return override live_constants = _CronStorePaths(CRON_DIR, JOBS_FILE, OUTPUT_DIR) if live_constants != _IMPORT_STORE: return live_constants home = get_hermes_home().resolve() if home == HERMES_DIR: return live_constants cron_dir = home / "cron" return _CronStorePaths(cron_dir, cron_dir / "jobs.json", cron_dir / "output") @contextlib.contextmanager def use_cron_store(home: Union[str, Path]): """Route cron storage to ``home`` without mutating process globals.""" cron_dir = Path(home).expanduser().resolve() / "cron" token = _cron_store_override.set( _CronStorePaths( cron_dir=cron_dir, jobs_file=cron_dir / "jobs.json", output_dir=cron_dir / "output", ) ) try: yield finally: _cron_store_override.reset(token) def get_cron_output_dir() -> Path: """Return the output directory for the active cron store context.""" return _current_cron_store().output_dir # Fallback stale-recovery window for a one-shot's running-claim (#59229) when # the cron inactivity timeout is disabled (HERMES_CRON_TIMEOUT=0 → unlimited), # in which case no finite run bound exists to derive from. Also acts as the # floor for the derived value so a very short configured timeout can't make the # claim expire mid-run. ONESHOT_RUN_CLAIM_TTL_SECONDS = 1800 # The derived TTL is the cron inactivity timeout times this headroom multiplier. # A healthy run clears its claim via mark_job_run() long before the TTL; the # TTL only recovers a claim left by a tick that DIED mid-run. HERMES_CRON_TIMEOUT # is an *inactivity* limit, not a wall-clock cap — a job that keeps producing # output legitimately runs past it — so the multiplier gives comfortable # headroom over any healthy run before we treat a claim as stale. _ONESHOT_RUN_CLAIM_TTL_HEADROOM = 3 _DEFAULT_CRON_INACTIVITY_TIMEOUT = 600.0 def _oneshot_run_claim_ttl_seconds() -> float: """Resolve the one-shot running-claim stale-recovery TTL. Derived from ``HERMES_CRON_TIMEOUT`` (the cron inactivity timeout the scheduler enforces on each run) so the safety valve tracks how long a run is actually allowed to go quiet, instead of a magic constant: - unset / invalid → default 600s inactivity limit → TTL = 1800s - ``0`` (unlimited runs) → no finite bound to derive from → fall back to ``ONESHOT_RUN_CLAIM_TTL_SECONDS`` - positive N → ``max(N * headroom, ONESHOT_RUN_CLAIM_TTL_SECONDS)`` so a tiny configured timeout can never expire a claim mid-run. """ raw = os.getenv("HERMES_CRON_TIMEOUT", "").strip() timeout = _DEFAULT_CRON_INACTIVITY_TIMEOUT if raw: try: timeout = float(raw) except (ValueError, TypeError): timeout = _DEFAULT_CRON_INACTIVITY_TIMEOUT if timeout <= 0: # Unlimited runs — cannot bound; use the fixed fallback floor. return float(ONESHOT_RUN_CLAIM_TTL_SECONDS) return max( timeout * _ONESHOT_RUN_CLAIM_TTL_HEADROOM, float(ONESHOT_RUN_CLAIM_TTL_SECONDS), ) def _job_running_in_this_process(job_id: str) -> bool: """Return True when the scheduler in THIS process is still running ``job_id``. Direct liveness signal for stale-entry recovery (#62002): the run_claim TTL alone cannot distinguish "the claiming tick died" from "the run is alive but slow" — a run stalled on network I/O (or a laptop that slept mid-run) legitimately outlives the TTL. The in-process ticker and the run share this process, so the scheduler's running set settles the common single-gateway case without any claim-age guesswork. Imported lazily: the scheduler imports this module at load, so a module-level import here would be circular. """ try: from cron.scheduler import get_running_job_ids return job_id in get_running_job_ids() except Exception: logger.warning( "Cron running-set liveness check failed for job %r; keeping the " "entry to avoid deleting a possibly live one-shot run", job_id, exc_info=True, ) return True def _jobs_lock_file() -> Path: """Return the advisory lock path for the current cron directory.""" return _current_cron_store().cron_dir / ".jobs.lock" @contextlib.contextmanager def _jobs_lock(): """Serialize a load_jobs→modify→save_jobs critical section. Combines the in-process threading lock (cheap mutual exclusion between the gateway's parallel tick threads) with a cross-process advisory file lock on ``/.jobs.lock`` (mutual exclusion between the gateway process and standalone ``hermes`` CLI invocations, which previously shared no lock at all — a `cron pause` could be silently clobbered by a concurrent gateway write, leaving a "paused" job still firing). The flock is blocking, but every critical section that uses it is short (field updates only — no agent execution), so contention resolves in milliseconds. If neither fcntl nor msvcrt is available the manager still provides in-process locking, matching the historical behaviour. Nested calls in the same thread reuse the held lock so legacy callers that invoke save_jobs() inside a broader mutation section don't deadlock or try to reacquire the advisory file lock. """ depth = getattr(_jobs_lock_state, "depth", 0) if depth: _jobs_lock_state.depth = depth + 1 try: yield finally: _jobs_lock_state.depth -= 1 return with _jobs_file_lock: _jobs_lock_state.depth = 1 # Stamp of jobs.json as of this section's load_jobs() (#80703's # fast-path, credit @JoaoMarcos44): lets _save_jobs_unlocked skip the # shrink-merge parse when the file provably hasn't changed since this # section read it. Reset on entry/exit so stale stamps from unlocked # loads or prior sections can never suppress a needed merge. _jobs_lock_state.load_stamp = None lock_fd = None try: try: ensure_dirs() lock_fd = open(_jobs_lock_file(), "a+", encoding="utf-8") lock_fd.seek(0) if fcntl is not None: # Bounded acquisition (#60703): a plain blocking # fcntl.flock(LOCK_EX) here has NO timeout, and it is # taken while holding the process-wide _jobs_file_lock # RLock above. If another process wedges while holding # .jobs.lock (e.g. an old gateway draining through a # restart), a single blocked acquirer freezes EVERY cron # function in this process — including the ticker's # get_due_jobs() — silently and forever: the heartbeat # file stops updating and all jobs stop firing with no # error logged. Poll LOCK_NB against a deadline instead; # on timeout, log loudly and fall through to the same # in-process-only degraded mode used when locking is # unavailable. A briefly-torn cross-process write is # strictly better than a permanently dead scheduler. _deadline = time.monotonic() + _JOBS_LOCK_TIMEOUT_SECONDS while True: try: fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) break except (OSError, IOError): if time.monotonic() >= _deadline: logger.error( "Timed out after %.0fs waiting for the cron " "jobs lock (%s) — another process is holding " "it. Proceeding with in-process locking only " "so the scheduler stays alive (#60703).", _JOBS_LOCK_TIMEOUT_SECONDS, _jobs_lock_file(), ) try: lock_fd.close() except OSError: pass lock_fd = None break time.sleep(0.1) elif msvcrt is not None: getattr(msvcrt, "locking")(lock_fd.fileno(), getattr(msvcrt, "LK_LOCK"), 1) except (OSError, IOError) as e: # Never let a locking failure take down cron writes — fall back to # in-process-only protection (still held via _jobs_file_lock). logger.warning("jobs.json cross-process lock unavailable (%s); " "proceeding with in-process lock only", e) try: yield finally: if lock_fd is not None: try: if fcntl is not None: fcntl.flock(lock_fd, fcntl.LOCK_UN) elif msvcrt is not None: getattr(msvcrt, "locking")(lock_fd.fileno(), getattr(msvcrt, "LK_UNLCK"), 1) except (OSError, IOError): pass finally: lock_fd.close() finally: _jobs_lock_state.depth = 0 _jobs_lock_state.load_stamp = None @contextlib.contextmanager def _fire_job_lock(job_id: str): """Serialize one job's owner mutations and external side effects. Unlike the global jobs lock, this lock may be held across network delivery. It is scoped to one profile + job, so unrelated cron jobs keep progressing. Fencing fails closed when cross-process locking is unavailable. """ cron_dir = _current_cron_store().cron_dir lock_key = f"{cron_dir.resolve()}::{job_id}" with _fire_fence_locks_guard: local_lock = _fire_fence_locks.setdefault(lock_key, threading.RLock()) if not local_lock.acquire(timeout=_JOBS_LOCK_TIMEOUT_SECONDS): logger.error("Timed out waiting for local fire fence %s; failing closed", lock_key) yield False return held_locks = getattr(_fire_fence_lock_state, "held", None) if held_locks is None: held_locks = {} _fire_fence_lock_state.held = held_locks if lock_key in held_locks: try: yield held_locks[lock_key] finally: local_lock.release() return try: ensure_dirs() lock_name = uuid.uuid5(uuid.NAMESPACE_URL, lock_key).hex lock_path = cron_dir / f".fire-{lock_name}.lock" lock_fd = None acquired = False try: lock_fd = open(lock_path, "a+", encoding="utf-8") lock_fd.seek(0) if fcntl is not None: deadline = time.monotonic() + _JOBS_LOCK_TIMEOUT_SECONDS while True: try: fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) acquired = True break except (OSError, IOError): if time.monotonic() >= deadline: logger.error( "Timed out waiting for fire fence %s; failing closed", lock_path, ) break time.sleep(0.1) elif msvcrt is not None: getattr(msvcrt, "locking")( lock_fd.fileno(), getattr(msvcrt, "LK_LOCK"), 1 ) acquired = True else: # pragma: no cover - supported platforms provide one backend logger.error("No cross-process lock backend for cron fire fence") except (OSError, IOError) as exc: logger.error("Cron fire fence unavailable for %s: %s", job_id, exc) held_locks[lock_key] = acquired try: yield acquired finally: held_locks.pop(lock_key, None) if lock_fd is not None: try: if acquired and fcntl is not None: fcntl.flock(lock_fd, fcntl.LOCK_UN) elif acquired and msvcrt is not None: getattr(msvcrt, "locking")( lock_fd.fileno(), getattr(msvcrt, "LK_UNLCK"), 1 ) except (OSError, IOError): pass finally: lock_fd.close() finally: local_lock.release() @contextlib.contextmanager def fire_claim_fence(job_id: str, *, expected_owner: str): """Hold a per-job fence while an owner performs an external side effect.""" with _fire_job_lock(job_id) as acquired: if not acquired: yield False return with _jobs_lock(): job = next((item for item in load_jobs() if item.get("id") == job_id), None) claim = job.get("fire_claim") if isinstance(job, dict) else None owns_claim = ( isinstance(claim, dict) and claim.get("by") == expected_owner ) yield owns_claim # Fields on a cron job that must never change after creation. ``id`` is used # as a filesystem path component under ``OUTPUT_DIR``; allowing it to be # updated lets an unsafe value (``../escape``, absolute path, nested) leak # into output writes/deletes. _IMMUTABLE_JOB_FIELDS = frozenset({"id"}) def _job_output_dir(job_id: str) -> Path: """Resolve a job's output directory, rejecting any path-escape attempt. Job IDs are filesystem path components under ``OUTPUT_DIR``. A legacy or crafted ID containing ``..``, absolute paths, or nested separators would allow output writes/deletes to escape the cron output sandbox. Reject anything that isn't a single safe path component. """ text = str(job_id or "").strip() if not text or text in {".", ".."} or "/" in text or "\\" in text: raise ValueError(f"Invalid cron job id for output path: {job_id!r}") if Path(text).is_absolute() or Path(text).drive: raise ValueError(f"Invalid cron job id for output path: {job_id!r}") return _current_cron_store().output_dir / text def _normalize_skill_list(skill: Optional[str] = None, skills: Optional[Any] = None) -> List[str]: """Normalize legacy/single-skill and multi-skill inputs into a unique ordered list.""" if skills is None: raw_items = [skill] if skill else [] elif isinstance(skills, str): raw_items = [skills] else: raw_items = list(skills) normalized: List[str] = [] for item in raw_items: text = str(item or "").strip() if text and text not in normalized: normalized.append(text) return normalized def _apply_skill_fields(job: Dict[str, Any]) -> Dict[str, Any]: """Return a job dict with canonical `skills` and legacy `skill` fields aligned.""" normalized = dict(job) skills = _normalize_skill_list(normalized.get("skill"), normalized.get("skills")) normalized["skills"] = skills normalized["skill"] = skills[0] if skills else None return normalized def _coerce_job_text(value: Any, fallback: str = "") -> str: """Coerce legacy/hand-edited nullable cron fields to strings for readers.""" if value is None: return fallback return str(value) # Fields whose presence in an update can turn a runnable job into an empty one. _PAYLOAD_FIELDS = frozenset({"prompt", "script", "skill", "skills", "no_agent"}) EMPTY_PAYLOAD_ERROR = ( "Cron job has nothing to run: the prompt is blank and no script or " "skill(s) are set. Provide a prompt, a script, or at least one skill." ) NO_AGENT_WITHOUT_SCRIPT_ERROR = ( "no_agent=True requires a script — with no agent and no script " "there is nothing for the job to run." ) def job_payload_is_empty(job: Dict[str, Any]) -> bool: """True when a job record has nothing runnable at all. A blank/whitespace prompt with no script and no skills would hand the agent an empty instruction on every fire (incident a5e29e688dc0). ``no_agent`` needs no special case here — it already requires a script. """ if _coerce_job_text(job.get("prompt")).strip(): return False if _coerce_job_text(job.get("script")).strip(): return False if _normalize_skill_list(job.get("skill"), job.get("skills")): return False # Only flag if at least one payload field is explicitly present in the record if "prompt" in job or "script" in job or "skill" in job or "skills" in job: return True return False def _schedule_display_for_job(job: Dict[str, Any]) -> str: display = _coerce_job_text(job.get("schedule_display")).strip() if display: return display schedule = job.get("schedule") if isinstance(schedule, dict): for key in ("display", "value", "expr", "run_at"): text = _coerce_job_text(schedule.get(key)).strip() if text: return text elif schedule is not None: return str(schedule) return "?" def _normalize_job_record(job: Dict[str, Any]) -> Dict[str, Any]: """Return a read-safe cron job shape for UI/API/tool/scheduler consumers. Older or hand-edited jobs can have nullable fields like ``prompt``, ``name``, or ``schedule_display``. Keep storage untouched on read, but ensure consumers never crash while formatting or running those records. """ normalized = _apply_skill_fields(job) job_id = _coerce_job_text(normalized.get("id"), "unknown") prompt = _coerce_job_text(normalized.get("prompt")) normalized["id"] = job_id normalized["prompt"] = prompt name = _coerce_job_text(normalized.get("name")).strip() if not name: script = _coerce_job_text(normalized.get("script")).strip() label_source = ( prompt or (normalized["skills"][0] if normalized.get("skills") else "") or script or job_id or "cron job" ) name = label_source[:50].strip() or "cron job" normalized["name"] = name normalized["schedule_display"] = _schedule_display_for_job(normalized) # Display state is derived from the scheduler-honoured ``enabled`` flag so a # half-paused record (enabled=true + state/paused_at) cannot render as # "paused" while the fleet is still live. See effective_job_state(). normalized["state"] = effective_job_state(normalized) return normalized def _has_pause_marker(job: Dict[str, Any]) -> bool: """True when the record carries any operator-facing pause signal.""" if _coerce_job_text(job.get("state")).strip() == "paused": return True return bool(job.get("paused_at")) def is_job_runnable(job: Dict[str, Any]) -> bool: """True iff the scheduler may fire this job. ``enabled`` is the scheduler-honoured flag. Pause markers (``state`` / ``paused_at``) are a second gate so a contradictory half-paused record never fires even before self-heal runs. """ if not job.get("enabled", True): return False if _has_pause_marker(job): return False return True def effective_job_state(job: Dict[str, Any]) -> str: """Operator-facing state derived from the scheduler-honoured flag. A job with ``enabled=true`` must never display as paused — that was the 07-30 outage failure mode (list looked frozen, fleet kept merging). Terminal states (completed/error) are preserved regardless of enabled. """ stored = _coerce_job_text(job.get("state")).strip() if stored in {"completed", "error"}: return stored if not job.get("enabled", True): if _has_pause_marker(job) or stored == "paused": return "paused" return stored or "paused" # enabled=true is authoritative: never claim paused if stored == "paused" or job.get("paused_at"): return "scheduled" return stored or "scheduled" def is_terminal_job(job: Dict[str, Any]) -> bool: """Return whether a job record is in a terminal scheduler state.""" return job.get("state") in {"completed", "error"} def _is_recoverable_error_job(job: Dict[str, Any]) -> bool: """True for a recurring job stuck in ``state=error``. ``state=error`` is set ONLY on a cron/interval job when ``compute_next_run()`` fails to produce a next occurrence (e.g. the ``croniter`` package is missing, or a malformed schedule) — see ``_mark_job_run_locked``'s issue #16265 comment: recurring jobs must NEVER be silently disabled. Unlike ``state=completed`` (a one-shot that genuinely has no more occurrences, ever), an error-state recurring job still has a schedule with future occurrences once the underlying issue resolves — it is stuck pending a ``next_run_at`` recompute, not truly done. ``is_terminal_job()`` treats both states identically, which is correct for blocking bare reactivation through ``update_job`` on a genuinely completed job, but wrong here: it also blocks the due-scan's own ``next_run_at`` self-heal (``_get_due_jobs_locked`` already recomputes it for ``cron``/``interval`` jobs, but never reaches that code), the at-most-once pre-advance (``advance_next_runs``), the dispatch claim (``_claim_job_for_fire_locked``), and manual recovery (``resume_job``) — wedging the job forever with no exit except deleting and recreating it. Callers that need "is this job truly done" should keep using ``is_terminal_job()`` alone; callers that need "can this job still reach a future occurrence" should exclude this case. """ return ( job.get("state") == "error" and (job.get("schedule") or {}).get("kind") in {"cron", "interval"} ) def _secure_dir(path: Path): """Set directory to owner-only access (0700). No-op on Windows.""" try: os.chmod(path, 0o700) except (OSError, NotImplementedError): pass # Windows or other platforms where chmod is not supported def _secure_file(path: Path): """Set file to owner-only read/write (0600). No-op on Windows.""" try: if path.exists(): os.chmod(path, 0o600) except (OSError, NotImplementedError): pass def _preserve_file_ownership(path: Path, before: Optional[os.stat_result]) -> None: """Restore a rewritten file's previous owner (POSIX, privileged writer only). The atomic-write pattern (mkstemp + replace) makes the rewritten file owned by the *writer's* euid. When a root shell runs a state-writing cron CLI command (``docker exec hermes hermes cron create ...`` — ``docker exec`` defaults to root) against a store owned by the unprivileged gateway user, the replace flips ``jobs.json`` to ``root:root`` mode 600 and the gateway's ticker (uid 1000) is silently locked out of every subsequent tick (#68483). Root can always hand ownership back, so do exactly that: when the euid is 0 and the pre-replace owner differs, chown the new file to the previous uid/gid. Unprivileged writers are a no-op (their own rewrite already heals a root-owned file back to their uid, and they couldn't chown anyway). No-op on Windows. Best-effort: a failure must never break the save. """ if before is None or os.name != "posix": return geteuid = getattr(os, "geteuid", None) getegid = getattr(os, "getegid", None) if geteuid is None or getegid is None: return try: euid = geteuid() if euid != 0: return # unprivileged writer — nothing to (or we could) restore if (before.st_uid, before.st_gid) == (euid, getegid()): return # already ours before the rewrite — nothing changed os.chown(path, before.st_uid, before.st_gid) except OSError as e: logger.warning( "Could not restore ownership of %s to uid=%s gid=%s after rewrite: %s " "— if the gateway runs as a different user, its cron ticker may now " "be locked out (see issue #68483).", path, before.st_uid, before.st_gid, e, ) def _is_named_profile_path(path: Path) -> bool: """Return True if *path* is inside a named profile home. Named profiles live under ``/profiles//``. The default profile lives at ```` directly (no ``profiles`` parent), as do custom ``HERMES_HOME`` paths outside ``~/.hermes``. Checks both the resolved path (handles symlinks in the parent chain) and the raw path (catches symlinked profile homes whose resolve() target no longer contains ``profiles``). """ try: if "profiles" in path.resolve().parts: return True except (OSError, RuntimeError): pass return "profiles" in path.parts def _ensure_cron_dir(cron_dir: Path) -> None: """Create a cron directory without resurrecting a deleted profile home. Named profiles are created by the profile lifecycle, not cron. A stale multiplex scheduler may still hold a path to a deleted profile after the user removes it; ``parents=False`` makes that race fail closed (FileNotFoundError) instead of silently restoring the directory tree. Default and custom Hermes homes keep ``parents=True`` so first-run directory creation still works. """ if _is_named_profile_path(cron_dir): cron_dir.mkdir(exist_ok=True) return cron_dir.mkdir(parents=True, exist_ok=True) def ensure_dirs(): """Ensure cron directories exist with secure permissions.""" store = _current_cron_store() _ensure_cron_dir(store.cron_dir) _ensure_cron_dir(store.output_dir) _secure_dir(store.cron_dir) _secure_dir(store.output_dir) # ============================================================================= # Schedule Parsing # ============================================================================= def normalize_repeat_value(repeat: Any) -> Optional[int]: """Coerce a repeat value from any entry point into ``Optional[int]``. The tool schema exposes ``repeat`` as an integer, but agents and users legitimately pass the user-facing strings ``'forever'``/``'once'`` or numeric strings (``'3'``). Uncoerced strings previously died with ``'<=' not supported between instances of 'str' and 'int'`` at create (#66824/#64520/#7142/#71987/#95706) and were stored raw by update paths, breaking ``mark_job_run`` later. Semantics: ``'forever'``-family -> None (infinite), ``'once'``-family -> 1, numeric -> int, 0/negative -> None, anything else -> ValueError (never store garbage). """ if repeat is None: return None if isinstance(repeat, str): repeat_str = repeat.strip().lower() if repeat_str in ("forever", "infinite", "inf", "none", ""): return None if repeat_str in ("once", "one", "1x"): return 1 try: repeat = int(repeat_str) except ValueError: raise ValueError( f"Invalid repeat value {repeat!r}: use an integer, " f"'forever', or 'once'." ) return None if repeat <= 0 else int(repeat) def parse_duration(s: str) -> int: """ Parse duration string into minutes. Examples: "30m" → 30 "2h" → 120 "1d" → 1440 "hour" → 60 (bare unit, no leading number) """ s = s.strip().lower() match = re.match(r'^(\d*)\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$', s) if not match: raise ValueError( f"Invalid duration: '{s}'. Use format like '30m', '2h', '1d', " "or a bare unit like 'hour' (defaults to 1)." ) value = int(match.group(1)) if match.group(1) else 1 unit = match.group(2)[0] # First char: m, h, or d multipliers = {'m': 1, 'h': 60, 'd': 1440} return value * multipliers[unit] # Natural-language day-spec phrases for the documented "every monday 9am" / # "every day at 9am" schedule forms. Cron weekday numbering is # 0=Sunday … 6=Saturday (croniter's default). _WEEKDAY_TO_CRON_DOW = { "sunday": "0", "sun": "0", "monday": "1", "mon": "1", "tuesday": "2", "tue": "2", "tues": "2", "wednesday": "3", "wed": "3", "weds": "3", "thursday": "4", "thu": "4", "thur": "4", "thurs": "4", "friday": "5", "fri": "5", "saturday": "6", "sat": "6", } # Keyword day-specs that expand to a cron weekday field. _DAYSPEC_TO_CRON_DOW = { "day": "*", "daily": "*", "everyday": "*", "weekday": "1-5", "weekdays": "1-5", "weekend": "0,6", "weekends": "0,6", } def _parse_clock_time(text: str) -> Optional[tuple]: """Parse a wall-clock time into a ``(hour, minute)`` 24-hour tuple. Accepts ``9am``, ``9:30am``, ``9 am``, ``14:00``, ``7`` (bare hour, 24h), ``noon``/``midday``, and ``midnight``. Returns None when the text is not a recognized clock time so the caller can reject the schedule cleanly. """ t = text.strip().lower().replace(" ", "") if not t: return None if t in ("noon", "midday"): return (12, 0) if t == "midnight": return (0, 0) match = re.match(r'^(\d{1,2})(?::(\d{2}))?(am|pm)?$', t) if not match: return None hour = int(match.group(1)) minute = int(match.group(2) or 0) meridiem = match.group(3) if meridiem: if not 1 <= hour <= 12: return None if meridiem == "am": hour = 0 if hour == 12 else hour else: # pm hour = 12 if hour == 12 else hour + 12 if hour > 23 or minute > 59: return None return (hour, minute) def _natural_every_to_cron(rest: str) -> Optional[str]: """Convert a documented ``every [at]