Files
hermes-agent/hermes_time.py
ethernet 97de4b4ef8 Merge origin/main into ethie/pm-clean
Conflict resolutions and semantic fixups:

- utils.py / hermes_yaml.py: main widened ruamel's round-trip emitter so a long
  double-quoted scalar is never folded after an escaped backslash. pm-clean builds
  every rt emitter through hermes_yaml.roundtrip_yaml(), so the width lives there
  (ROUNDTRIP_YAML_WIDTH moves with it); xai_retirement imports it from hermes_yaml.
- hermes_cli/banner.py: keep pm-clean's removal of the banner update check. Main's
  GIT_NO_LAZY_FETCH fix for it applies to its replacement, source_check: every
  read-only probe (source_git_env) now refuses promisor lazy fetches, and the
  partial-clone test targets that probe (red without the flag).
- .github/workflows/tests.yml: keep setup-pm; main's uv pin bump does not apply.
  Main's WAL-capable SQLite gates are kept, run against $HERMES_PYTHON (the
  PM-pinned interpreter, SQLite 3.53.1). The e2e step takes main's
  --include-integration invocation.
- apps/desktop: package.json has no build block here, so main's macOS locale-marker
  restore joins the darwin branch of the existing after-pack.mjs, and its test
  loads the hook from electron-builder.config.cjs and imports PlatformPackager
  from app-builder-lib's root (electron-builder 27 exports no ./out paths). The
  win32 row is dropped: this hook sanitizes and signs PE trees on win32 by design.
- reconciliation.ts: main's rowId hydration (#119326) was merged into the first of
  pm-clean's split helpers only; the resolver is now one helper both halves use.
- en.ts: both sides' keys kept. tests/tools/test_lazy_deps.py stays deleted.
- Tests main added with `import yaml` use hermes_yaml, like the rest of the tree.
2026-09-23 19:51:34 -04:00

150 lines
6.4 KiB
Python

"""Timezone-aware clock for Hermes.
``now()`` returns a tz-aware datetime in the user's configured IANA timezone. Resolution order:
``HERMES_TIMEZONE`` env var, then ``timezone`` in ``~/.hermes/config.yaml``, else server-local
time. Invalid timezone values log a warning and fall back — never crash.
"""
import locale
import logging
import os
import re
import threading
from datetime import datetime
from typing import Dict, Optional, Tuple
from zoneinfo import ZoneInfo
from hermes_constants import get_config_path
logger = logging.getLogger(__name__)
# Cache keyed by timezone *source* identity. This process can multiplex profiles by switching
# HERMES_HOME, so one unkeyed global would leak the first profile's timezone into later
# profile-scoped work (e.g. the desktop multiplex cron ticker persisting another profile's
# ``next_run_at``). Entries are published atomically under ``_cache_lock`` as one
# ``identity -> (name, ZoneInfo | None)`` value, so racing resolvers can never publish a mixed
# identity/value pair. Call reset_cache() after in-place config changes.
_cache_lock = threading.Lock()
_tz_cache: Dict[Tuple[str, str], Tuple[str, Optional[ZoneInfo]]] = {}
_SURROGATE_RE = re.compile(r"[\ud800-\udfff]")
# ASCII plus surrogateescape'd bytes only: the shape of native text decoded with the wrong codec.
_ESCAPED_BYTES_RE = re.compile(r"[\x00-\x7f\udc80-\udcff]*")
def _repair_surrogates(text: str, encoding: Optional[str] = None) -> str:
"""Make locale text JSON/UTF-8 safe; a no-op (same object) on valid text.
Windows hands back zone names in the ANSI code page (``heure d'\\xe9t\\xe9``) but a UTF-8
``LC_CTYPE`` (UTF-8 mode, or a library flipping the process locale mid-run) decodes those
bytes with ``surrogateescape`` into lone surrogates. Recover the bytes and decode them with
the ANSI code page; when that is not possible, replace each surrogate with U+FFFD."""
if text.isascii() or not _SURROGATE_RE.search(text):
return text
if _ESCAPED_BYTES_RE.fullmatch(text):
try:
return text.encode("ascii", "surrogateescape").decode(encoding or locale.getencoding())
except (LookupError, UnicodeError):
pass
return _SURROGATE_RE.sub("\ufffd", text)
def safe_strftime(value: datetime, fmt: str) -> str:
"""``value.strftime(fmt)`` that never raises or returns lone surrogates over locale text.
``datetime.strftime`` splices ``tzname()`` into the format as UTF-8, so a zone name carrying
surrogates (see ``_repair_surrogates``) raises ``UnicodeEncodeError`` before any string exists
(#102910). On that failure ``%Z`` is rendered from the repaired name instead. Output for valid
locale text is byte-identical to ``strftime`` (the system prompt must stay cache-stable)."""
try:
return _repair_surrogates(value.strftime(fmt))
except UnicodeEncodeError:
zone = _repair_surrogates(value.tzname() or "").replace("%", "%%")
return _repair_surrogates(value.strftime(
re.sub(r"%([%Z])", lambda m: zone if m.group(1) == "Z" else "%%", fmt)))
def _env_timezone() -> str:
"""``HERMES_TIMEZONE`` when it may speak for the active profile. Under the multiplexed
gateway the env var holds only the DEFAULT profile's value (bridged from its config.yaml at
startup), so every routed profile must read its own config.yaml instead."""
from agent.secret_scope import is_multiplex_active # lazy: secret_scope pulls in more than a clock needs
if is_multiplex_active():
return ""
return os.getenv("HERMES_TIMEZONE", "").strip()
def _timezone_cache_identity() -> Tuple[str, str]:
tz_env = _env_timezone()
return ("environment", tz_env) if tz_env else ("config", str(get_config_path()))
def _resolve_timezone_name() -> str:
"""Read the configured IANA timezone string (or ``""``). Does file I/O — callers cache."""
tz_env = _env_timezone()
if tz_env:
return tz_env
try:
# Prefer the shared cached effective-config loader (mtime-keyed + libyaml, managed overlay
# included so an administrator can pin ``timezone``): a direct safe_load of a large
# config.yaml costs ~100 ms and this ran inside the FIRST system prompt build. The bare
# parse is the stdlib-safe fallback for bootstrap consumers without hermes_cli importable.
try:
from hermes_cli.config_effective import load_user_config_effective
cfg = load_user_config_effective(get_config_path())
except Exception:
import hermes_yaml as yaml
config_path = get_config_path()
cfg = (yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}) if config_path.exists() else {}
if cfg:
tz_cfg = cfg.get("timezone", "")
if isinstance(tz_cfg, str) and tz_cfg.strip():
return tz_cfg.strip()
except Exception:
pass
return ""
def _timezone_entry() -> Tuple[str, Optional[ZoneInfo]]:
"""Cached ``(configured name, ZoneInfo | None)`` for the active profile."""
cache_identity = _timezone_cache_identity()
with _cache_lock:
entry = _tz_cache.get(cache_identity)
if entry is not None:
return entry
# Resolve outside the lock (config file I/O); first writer wins so concurrent resolvers of the
# same identity converge on one ZoneInfo object.
name = _resolve_timezone_name()
tz = None
if name:
try:
tz = ZoneInfo(name)
except Exception as exc:
logger.warning("Invalid timezone '%s': %s. Falling back to server local time.", name, exc)
with _cache_lock:
return _tz_cache.setdefault(cache_identity, (name, tz))
def get_timezone() -> Optional[ZoneInfo]:
"""Return the active profile's configured ZoneInfo, or None (server-local)."""
return _timezone_entry()[1]
def get_timezone_name() -> str:
"""The active profile's configured IANA timezone string, or ``""`` (server-local). Same
resolution and cache as :func:`get_timezone`; for handing ``TZ`` to sandboxed children."""
return _timezone_entry()[0]
def reset_cache() -> None:
"""Clear the cached timezone so the next call re-resolves it (after config/env changes)."""
with _cache_lock:
_tz_cache.clear()
def now() -> datetime:
"""Current time as a tz-aware datetime: configured zone, else server-local."""
tz = get_timezone()
return datetime.now(tz) if tz is not None else datetime.now().astimezone()