Files
hermes-agent/tests/cron/test_timezone.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

357 lines
14 KiB
Python

"""
Tests for timezone support (hermes_time module + integration points).
Covers:
- Valid timezone applies correctly
- Invalid timezone falls back safely (no crash, warning logged)
- execute_code child env receives TZ
- Cron uses timezone-aware now()
- Backward compatibility with naive timestamps
"""
import os
import sys
import pytest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import hermes_time
def _reset_hermes_time_cache():
"""Reset the hermes_time module cache."""
hermes_time.reset_cache()
# =========================================================================
# hermes_time.now() — core helper
# =========================================================================
class TestHermesTimeNow:
"""Test the timezone-aware now() helper."""
def setup_method(self):
_reset_hermes_time_cache()
def teardown_method(self):
_reset_hermes_time_cache()
os.environ.pop("HERMES_TIMEZONE", None)
def test_valid_timezone_applies(self):
"""With a valid IANA timezone, now() returns time in that zone."""
os.environ["HERMES_TIMEZONE"] = "Asia/Kolkata"
result = hermes_time.now()
assert result.tzinfo is not None
# IST is UTC+5:30
offset = result.utcoffset()
assert offset == timedelta(hours=5, minutes=30)
# Windows zone name in cp1252 bytes, decoded under a UTF-8 LC_CTYPE with surrogateescape (#102910).
_ESCAPED_ZONE = "Paris, Madrid (heure d'\udce9t\udce9)"
class TestSafeStrftime:
def test_valid_locale_text_is_byte_identical_to_strftime(self):
"""The system prompt embeds this output, so healthy locales must not change a byte."""
from zoneinfo import ZoneInfo
fmt = "%a %A %b %B %d %Y %H:%M:%S %Z %z %%Z"
for value in (
datetime(2026, 7, 14, 13, 5),
datetime(2026, 7, 14, 13, 5, tzinfo=ZoneInfo("Europe/Paris")),
datetime(2026, 7, 14, 13, 5, tzinfo=timezone(timedelta(hours=-3), "Hora estándar de Argentina")),
):
assert hermes_time.safe_strftime(value, fmt) == value.strftime(fmt)
def test_surrogate_zone_name_renders_json_safe(self):
import json
value = datetime(2026, 7, 14, 13, 5, tzinfo=timezone(timedelta(hours=2), _ESCAPED_ZONE))
rendered = hermes_time.safe_strftime(value, "%a %Y-%m-%d %H:%M %Z %z %%Z")
json.dumps(rendered, ensure_ascii=False).encode("utf-8")
assert rendered.startswith("Tue 2026-07-14 13:05 Paris, Madrid (heure d'")
assert rendered.endswith(") +0200 %Z")
# The escaped bytes decode back through the Windows ANSI code page.
assert hermes_time._repair_surrogates(_ESCAPED_ZONE, "cp1252") == "Paris, Madrid (heure d'été)"
class TestGetTimezone:
"""Test get_timezone()."""
def setup_method(self):
_reset_hermes_time_cache()
def teardown_method(self):
_reset_hermes_time_cache()
os.environ.pop("HERMES_TIMEZONE", None)
def test_cache_isolated_by_active_profile_config(self, tmp_path, monkeypatch):
"""Switching HERMES_HOME must not reuse another profile's timezone."""
first_home = tmp_path / "first"
second_home = tmp_path / "second"
first_home.mkdir()
second_home.mkdir()
(first_home / "config.yaml").write_text("timezone: Asia/Tokyo\n", encoding="utf-8")
(second_home / "config.yaml").write_text(
"timezone: America/New_York\n", encoding="utf-8"
)
monkeypatch.delenv("HERMES_TIMEZONE", raising=False)
monkeypatch.setenv("HERMES_HOME", str(first_home))
assert str(hermes_time.get_timezone()) == "Asia/Tokyo"
# Multiplexed profile runtime scopes switch HERMES_HOME in one process.
monkeypatch.setenv("HERMES_HOME", str(second_home))
assert str(hermes_time.get_timezone()) == "America/New_York"
# Switching BACK must return the first profile's zone (per-identity
# entries stay hot; no single-slot ping-pong).
monkeypatch.setenv("HERMES_HOME", str(first_home))
assert str(hermes_time.get_timezone()) == "Asia/Tokyo"
def test_multiplex_prefers_routed_profile_config_over_env(self, tmp_path, monkeypatch):
"""Under the multiplexed gateway HERMES_TIMEZONE holds only the DEFAULT profile's value
(bridged at startup), so a routed profile must resolve from its own config.yaml."""
from agent.secret_scope import set_multiplex_active
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
default_home, routed_home = tmp_path / "default", tmp_path / "routed"
default_home.mkdir()
routed_home.mkdir()
(default_home / "config.yaml").write_text("timezone: America/New_York\n", encoding="utf-8")
(routed_home / "config.yaml").write_text("timezone: Asia/Tokyo\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(default_home))
monkeypatch.setenv("HERMES_TIMEZONE", "America/New_York")
# Single-profile process: env stays authoritative.
assert hermes_time.get_timezone_name() == "America/New_York"
set_multiplex_active(True)
try:
assert hermes_time.get_timezone_name() == "America/New_York" # default profile turn
token = set_hermes_home_override(str(routed_home))
try:
assert hermes_time.get_timezone_name() == "Asia/Tokyo"
assert str(hermes_time.get_timezone()) == "Asia/Tokyo"
finally:
reset_hermes_home_override(token)
finally:
set_multiplex_active(False)
def test_concurrent_profile_resolution_never_mixes_zones(
self, tmp_path, monkeypatch
):
"""Racing profile-scoped threads must never observe a foreign zone.
The multiplex cron ticker lets profile-A work (mark_job_run /
compute_next_run) overlap the ticker advancing to profile B. The
cache publication must be atomic per identity: identity A can never
be paired with profile B's ZoneInfo (#97905 review finding on
PR #92489).
"""
import threading
from hermes_constants import (
reset_hermes_home_override,
set_hermes_home_override,
)
zones = {"a": "Asia/Tokyo", "b": "America/New_York"}
homes = {}
for key, zone in zones.items():
home = tmp_path / key
home.mkdir()
(home / "config.yaml").write_text(
f"timezone: {zone}\n", encoding="utf-8"
)
homes[key] = home
monkeypatch.delenv("HERMES_TIMEZONE", raising=False)
errors = []
barrier = threading.Barrier(2)
def worker(key: str) -> None:
barrier.wait()
for _ in range(200):
token = set_hermes_home_override(str(homes[key]))
try:
tz = hermes_time.get_timezone()
if str(tz) != zones[key]:
errors.append((key, str(tz)))
return
finally:
reset_hermes_home_override(token)
threads = [
threading.Thread(target=worker, args=(key,)) for key in zones
]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors, f"foreign timezone observed: {errors}"
# =========================================================================
# execute_code child env — TZ injection
# =========================================================================
@pytest.mark.platforms("posix") # UDS not available on Windows
class TestCodeExecutionTZ:
"""Verify TZ env var is passed to sandboxed child process via real execute_code."""
@pytest.fixture(autouse=True)
def _import_execute_code(self, monkeypatch):
"""Lazy-import execute_code to avoid pulling in firecrawl at collection time."""
# Force local backend — other tests in the same process may leak
# TERMINAL_ENV=modal/docker which causes modal.exception.AuthError.
monkeypatch.setenv("TERMINAL_ENV", "local")
try:
from tools.code_execution_tool import execute_code
self._execute_code = execute_code
except ImportError:
pytest.skip("tools.code_execution_tool not importable (missing deps)")
def teardown_method(self):
os.environ.pop("HERMES_TIMEZONE", None)
def _mock_handle(self, function_name, function_args, task_id=None, user_task=None):
import json as _json
return _json.dumps({"error": f"unexpected tool call: {function_name}"})
def test_tz_injected_when_configured(self):
"""When HERMES_TIMEZONE is set, child process sees TZ env var.
Verified alongside leak-prevention + empty-TZ handling in one
subprocess call so we don't pay 3x the subprocess startup cost
(each execute_code spawns a real Python subprocess ~3s).
"""
import json as _json
os.environ["HERMES_TIMEZONE"] = "Asia/Kolkata"
# One subprocess, three things checked:
# 1) TZ is injected as "Asia/Kolkata"
# 2) HERMES_TIMEZONE itself does NOT leak into the child env
probe = (
'import os; '
'print("TZ=" + os.environ.get("TZ", "NOT_SET")); '
'print("HERMES_TIMEZONE=" + os.environ.get("HERMES_TIMEZONE", "NOT_SET"))'
)
with patch("model_tools.handle_function_call", side_effect=self._mock_handle):
result = _json.loads(self._execute_code(
code=probe,
task_id="tz-combined-test",
enabled_tools=[],
))
assert result["status"] == "success"
assert "TZ=Asia/Kolkata" in result["output"]
assert "HERMES_TIMEZONE=NOT_SET" in result["output"], (
"HERMES_TIMEZONE should not leak into child env (only TZ)"
)
# =========================================================================
# Cron timezone-aware scheduling
# =========================================================================
class TestCronTimezone:
"""Verify cron paths use timezone-aware now()."""
def setup_method(self):
_reset_hermes_time_cache()
def teardown_method(self):
_reset_hermes_time_cache()
os.environ.pop("HERMES_TIMEZONE", None)
def test_parse_schedule_one_shot_duration_uses_tz_aware_now(self):
"""parse_schedule('in 30m') should produce a tz-aware run_at."""
os.environ["HERMES_TIMEZONE"] = "Asia/Kolkata"
from cron.jobs import parse_schedule
result = parse_schedule("in 30m")
run_at = datetime.fromisoformat(result["run_at"])
# The stored timestamp should be tz-aware
assert run_at.tzinfo is not None
def test_ensure_aware_naive_preserves_absolute_time(self):
"""_ensure_aware must preserve the absolute instant for naive datetimes.
Regression: the old code used replace(tzinfo=hermes_tz) which shifted
absolute time when system-local tz != Hermes tz. The fix interprets
naive values as system-local wall time, then converts.
"""
from cron.jobs import _ensure_aware
os.environ["HERMES_TIMEZONE"] = "Asia/Kolkata"
_reset_hermes_time_cache()
# Create a naive datetime — will be interpreted as system-local time
naive_dt = datetime(2026, 3, 11, 12, 0, 0)
result = _ensure_aware(naive_dt)
# The result should be in Kolkata tz
assert result.tzinfo is not None
# The UTC equivalent must match what we'd get by correctly interpreting
# the naive dt as system-local time first, then converting
system_tz = datetime.now().astimezone().tzinfo
expected_utc = naive_dt.replace(tzinfo=system_tz).astimezone(timezone.utc)
actual_utc = result.astimezone(timezone.utc)
assert actual_utc == expected_utc, (
f"Absolute time shifted: expected {expected_utc}, got {actual_utc}"
)
def test_get_due_jobs_naive_cross_timezone(self, tmp_path, monkeypatch):
"""Naive past timestamps must be detected as due even when Hermes tz
is behind system local tz — the scenario that triggered #806."""
import cron.jobs as jobs_module
monkeypatch.setattr(jobs_module, "CRON_DIR", tmp_path / "cron")
monkeypatch.setattr(jobs_module, "JOBS_FILE", tmp_path / "cron" / "jobs.json")
monkeypatch.setattr(jobs_module, "OUTPUT_DIR", tmp_path / "cron" / "output")
# Use a Hermes timezone far behind UTC so that the numeric wall time
# of the naive timestamp exceeds _hermes_now's wall time — this would
# have caused a false "not due" with the old replace(tzinfo=...) approach.
os.environ["HERMES_TIMEZONE"] = "Pacific/Midway" # UTC-11
_reset_hermes_time_cache()
from cron.jobs import create_job, load_jobs, save_jobs, get_due_jobs
create_job(prompt="Cross-tz job", schedule="every 1h")
jobs = load_jobs()
# Force a naive past timestamp (system-local wall time, 10 min ago)
naive_past = (datetime.now() - timedelta(seconds=30)).isoformat()
jobs[0]["next_run_at"] = naive_past
save_jobs(jobs)
due = get_due_jobs()
assert len(due) == 1, (
"Naive past timestamp should be due regardless of Hermes timezone"
)
def test_create_job_stores_tz_aware_timestamps(self, tmp_path, monkeypatch):
"""New jobs store timezone-aware created_at and next_run_at."""
import cron.jobs as jobs_module
monkeypatch.setattr(jobs_module, "CRON_DIR", tmp_path / "cron")
monkeypatch.setattr(jobs_module, "JOBS_FILE", tmp_path / "cron" / "jobs.json")
monkeypatch.setattr(jobs_module, "OUTPUT_DIR", tmp_path / "cron" / "output")
os.environ["HERMES_TIMEZONE"] = "US/Eastern"
_reset_hermes_time_cache()
from cron.jobs import create_job
job = create_job(prompt="TZ test", schedule="every 2h")
created = datetime.fromisoformat(job["created_at"])
assert created.tzinfo is not None
next_run = datetime.fromisoformat(job["next_run_at"])
assert next_run.tzinfo is not None