Files
hermes-agent/tests/hermes_cli/test_cron_parser_builder.py
T
yliu325 bfa67878cd fix(cron): per-job timezone drives schedule compute and due-scan
A job may pin its own IANA timezone; its cron expression is then that zone's wall clock,
independent of the profile's configured timezone. Absent/None inherits the profile, so an
existing jobs.json keeps byte-identical behaviour and needs no migration.

The dispatch path needs the pin as much as the compute does. `compute_next_run()` resolves
the zone as job pin -> profile zone -> base offset, and every site that writes `next_run_at`
now passes it (advance, claim, resume, re-arm, recovery, and the quota/unreachable
planners); one missed site re-fires or skips a slot because the stored instant and the
expression would be read in different zones.

`_evaluate_due_job()` normalizes the stored instant into the JOB's zone, which hands every
downstream cron predicate the job's clock, and a pinned job skips
`_repair_timezone_shifted_cron` outright. That repair exists for jobs that FOLLOW the profile
zone across a zone change: on a pinned job its wall-clock comparison reads a due occurrence
as still-in-the-future and re-anchors past it.

Failing shape this fixes (profile America/Los_Angeles, job 0 9 * * 1-5 pinned to
America/New_York, scanned at 09:00 New York): `get_due_jobs()` returned [] and the job was
re-anchored to the next day, i.e. job zones east of the profile zone silently never fired.
The regression test drives the public store API and asserts both the fire and the absence of
the repair, and is proven red on main ([] and a rewritten instant).

Knobs live in the new sibling `cron/jobs_time.py`; `cron/jobs.py` stays over the file-lines
ratchet and only gained the call-site plumbing.
2026-10-28 19:40:40 -07:00

55 lines
1.8 KiB
Python

"""Unit tests for the extracted ``hermes cron`` parser builder.
Confirms ``build_cron_parser`` wires up the same subactions, aliases, options,
and ``func=cmd_cron`` dispatch that lived inline in ``main()`` before the
god-file Phase 2 extraction.
"""
from __future__ import annotations
import argparse
from hermes_cli.subcommands.cron import build_cron_parser
def _sentinel_handler(args): # pragma: no cover - only identity is asserted
return "cron-handler"
def _build():
parser = argparse.ArgumentParser(prog="hermes")
subparsers = parser.add_subparsers(dest="command")
build_cron_parser(subparsers, cmd_cron=_sentinel_handler)
return parser
def test_cron_edit_no_agent_tristate():
parser = _build()
# --no-agent -> True, --agent -> False, neither -> None
assert parser.parse_args(["cron", "edit", "j", "--no-agent"]).no_agent is True
assert parser.parse_args(["cron", "edit", "j", "--agent"]).no_agent is False
assert parser.parse_args(["cron", "edit", "j"]).no_agent is None
def test_cron_accept_hooks_flag_on_run_and_tick():
parser = _build()
# --accept-hooks is suppressed-default; present only when passed.
ns = parser.parse_args(["cron", "run", "jid", "--accept-hooks"])
assert ns.accept_hooks is True
ns2 = parser.parse_args(["cron", "tick", "--accept-hooks"])
assert ns2.accept_hooks is True
def test_cron_timezone_flags():
parser = _build()
created = parser.parse_args(
["cron", "create", "0 9 * * *", "hi", "--timezone", "Asia/Hong_Kong"])
assert created.timezone == "Asia/Hong_Kong"
edited = parser.parse_args(["cron", "edit", "j", "--timezone", ""])
assert edited.timezone == "", "an empty value is the documented clear"
assert parser.parse_args(["cron", "edit", "j"]).timezone is None