"""Cron job storage: ~/.hermes/cron/jobs.json; output in ~/.hermes/cron/output/{job_id}/{timestamp}.md""" import contextlib import copy from contextvars import ContextVar from dataclasses import dataclass, field import json import logging import shutil import tempfile import threading import time import os import re import uuid # Cross-process advisory locking for jobs.json: fcntl (Unix) or msvcrt (Windows). If both are # absent, _jobs_lock() degrades to in-process locking 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, Callable, 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 is imported lazily (slow import, only needed for cron exprs). HAS_CRONITER stays a # module attribute: a monkeypatched value wins because _ensure_croniter only probes while 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: anchor at get_hermes_home() (active profile home), NOT # get_default_hermes_root() — the shared root would funnel every profile's jobs into one jobs.json # and run them under the ticker's HERMES_HOME, leaking config/credentials/skills across profiles. HERMES_DIR = get_hermes_home().resolve() # Default-profile fallback and compatibility surface for callers/tests. Cross-profile callers must # scope paths with use_cron_store() instead of mutating these process-wide. CRON_DIR = HERMES_DIR / "cron" JOBS_FILE = CRON_DIR / "jobs.json" # Touched by the ticker every loop so `hermes cron status` can tell the ticker THREAD is alive, # not just the gateway PROCESS (a silently dead ticker would otherwise report healthy). TICKER_HEARTBEAT_FILE = CRON_DIR / "ticker_heartbeat" # Last tick that completed WITHOUT raising — lets status detect a ticker alive but failing every tick. TICKER_SUCCESS_FILE = CRON_DIR / "ticker_last_success" # Single source of truth for the ticker interval (scheduler_provider.py) and the staleness # threshold in `hermes cron status` (hermes_cli/cron.py), so they never drift apart. TICKER_INTERVAL_SECONDS = 60 # In-process lock for load_jobs→modify→save_jobs cycles; without it, parallel tick threads' # mark_job_run / advance_next_run calls 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. Every cron function funnels through # _jobs_lock(), so blocking forever on a wedged sibling process would freeze the ticker and every # job. 30s is far above any legitimate critical section yet 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 @classmethod def for_dir(cls, cron_dir: Path) -> "_CronStorePaths": return cls(cron_dir, cron_dir / "jobs.json", cron_dir / "output") _cron_store_override: ContextVar[Optional[_CronStorePaths]] = ContextVar( "cron_store_override", default=None, ) # Import-time snapshot so deliberate re-pointing of CRON_DIR/JOBS_FILE/OUTPUT_DIR (the documented # escape hatch for tests/embedders) 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: (1) active use_cron_store() override; (2) deliberately re-pointed module constants (differ from import-time values → honor the compatibility surface); (3) the ACTIVE profile home resolved fresh via get_hermes_home(), so re-pointing HERMES_HOME after import reads/writes ITS OWN store rather than the user's real jobs.json frozen at import; (4) the import-time constants. """ 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 return _CronStorePaths.for_dir(home / "cron") @contextlib.contextmanager def use_cron_store(home: Union[str, Path]): """Route cron storage to ``home`` without mutating process globals.""" token = _cron_store_override.set(_CronStorePaths.for_dir(Path(home).expanduser().resolve() / "cron")) 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 when HERMES_CRON_TIMEOUT=0 # (unlimited, no bound to derive from); also the floor so a tiny timeout can't expire a claim mid-run. ONESHOT_RUN_CLAIM_TTL_SECONDS = 1800 # Derived TTL = inactivity timeout × this headroom. The TTL only recovers a claim left by a tick that # DIED mid-run; the timeout is an *inactivity* limit, not a wall-clock cap, so healthy runs may # legitimately exceed it — hence the headroom. _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 TTL from ``HERMES_CRON_TIMEOUT``. unset/invalid → 600s → 1800s; ``0`` (unlimited) → ``ONESHOT_RUN_CLAIM_TTL_SECONDS``; positive N → ``max(N * headroom, ONESHOT_RUN_CLAIM_TTL_SECONDS)`` so a tiny timeout never expires 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``. The run_claim TTL alone cannot distinguish "claiming tick died" from "alive but slow" (a run stalled on I/O or a slept laptop legitimately outlives it); the in-process running set settles the single-gateway case. Lazy import: the scheduler imports this module, so top-level 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" def _acquire_flock(lock_fd, timeout: float) -> Optional[bool]: """Bounded exclusive lock: True when acquired, False on timeout, None when no backend exists. A blocking flock(LOCK_EX) has NO timeout and is taken while holding the in-process lock, so a wedged sibling process would freeze EVERY cron function here forever. Poll LOCK_NB against a deadline instead; the caller decides the degraded mode on timeout. """ if fcntl is not None: deadline = time.monotonic() + timeout while True: try: fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) return True except (OSError, IOError): if time.monotonic() >= deadline: return False time.sleep(0.1) if msvcrt is not None: getattr(msvcrt, "locking")(lock_fd.fileno(), getattr(msvcrt, "LK_LOCK"), 1) return True return None def _release_flock(lock_fd) -> None: """Unlock (best effort) and close a lock file opened for ``_acquire_flock``.""" 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() @contextlib.contextmanager def _jobs_lock(): """Serialize a load_jobs→modify→save_jobs critical section. In-process RLock (parallel tick threads) plus a cross-process flock on ``/.jobs.lock`` (gateway vs. CLI writes — otherwise a `cron pause` could be clobbered and keep firing). Sections are short (field updates only). Nested calls in one thread reuse the held lock. Without a flock backend, or on flock timeout (logged loudly), it degrades to in-process-only locking: a briefly torn cross-process write beats a dead scheduler. """ 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 # jobs.json stamp as of this section's load_jobs(): lets _save_jobs_unlocked skip the # shrink-merge parse when the file provably hasn't changed. 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 _acquire_flock(lock_fd, _JOBS_LOCK_TIMEOUT_SECONDS) is False: 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 except (OSError, IOError) as e: # A locking failure must never take down cron writes — in-process lock still held. 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: _release_flock(lock_fd) 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 may be held across network delivery; scoped to one profile + job so unrelated jobs keep progressing. 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) result = _acquire_flock(lock_fd, _JOBS_LOCK_TIMEOUT_SECONDS) if result is None: # pragma: no cover - supported platforms provide one backend logger.error("No cross-process lock backend for cron fire fence") elif not result: logger.error("Timed out waiting for fire fence %s; failing closed", lock_path) acquired = bool(result) 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: if acquired: _release_flock(lock_fd) else: 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 that must never change after creation: ``id`` is a path component under OUTPUT_DIR, so an # update could leak ``../escape``/absolute/nested values 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. IDs containing ``..``, absolute paths, or separators would let output writes/deletes escape the sandbox; only a single safe path component is accepted. """ 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 (blank prompt, no script, no skills). ``no_agent`` needs no special case — 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 return any(k in job for k in ("prompt", "script", "skill", "skills")) 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 job shape: legacy/hand-edited records may have nullable ``prompt``, ``name``, ``schedule_display``. Storage is untouched; consumers never crash on formatting.""" 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) # Derived from the scheduler-honoured ``enabled`` flag so a half-paused record cannot render # "paused" while still firing. 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`` plus pause markers as a second gate so a contradictory half-paused record never fires even before self-heal runs.""" if not job.get("enabled", True): return False return not _has_pause_marker(job) def effective_job_state(job: Dict[str, Any]) -> str: """Operator-facing state derived from ``enabled``: an enabled job must never display as paused (list looked frozen while jobs kept firing). Terminal states are preserved regardless.""" 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 when ``compute_next_run()`` fails for a cron/interval job (croniter missing, malformed schedule); recurring jobs must NEVER be silently disabled, and unlike ``completed`` such a job still has future occurrences once the issue resolves. Treating it as terminal would block the due-scan self-heal, at-most-once pre-advance, dispatch claim, and ``resume_job`` — wedging it forever. Use ``is_terminal_job()`` for "truly done"; exclude this case for "can still reach a future occurrence". """ 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, root writer only). Atomic replace makes the file owned by the writer's euid; a root CLI write (e.g. ``docker exec``) against the unprivileged gateway's store would flip jobs.json to root:root 0600 and lock the ticker out of every later tick. Root can hand ownership back, so chown when the owner differed. """ 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: """True if *path* is under ``/profiles//`` (default/custom homes are not). Checks the resolved path (symlinked parents) and the raw path (symlinked profile homes whose 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. A stale multiplex scheduler may still hold a deleted profile's path; ``parents=False`` makes that race fail closed instead of restoring the tree. Default/custom homes keep ``parents=True`` so first-run creation 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 (int or user-facing string) into ``Optional[int]``. ``'forever'``-family -> None (infinite), ``'once'``-family -> 1, numeric -> int, 0/negative -> None, anything else -> ValueError (never store garbage that breaks ``mark_job_run`` later). """ 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 a duration string into minutes: "30m" → 30, "2h" → 120, "1d" → 1440, bare "hour" → 60.""" 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] # Day-spec phrases for "every monday 9am" / "every day at 9am". 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 ``9am``/``9:30am``/``14:00``/``7`` (bare 24h hour)/``noon``/``midnight`` into a 24-hour ``(hour, minute)`` tuple, or None when unrecognized.""" 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 `` [at]