Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -3008,15 +3008,21 @@ def run_job(

agent = None

# Mark this as a cron session so the approval system can apply cron_mode.
# This env var is process-wide and persists for the lifetime of the
# scheduler process — every job this process runs is a cron job.
os.environ["HERMES_CRON_SESSION"] = "1"

# Use ContextVars for per-job session/delivery state so parallel jobs
# don't clobber each other's targets (os.environ is process-global).
from gateway.session_context import set_session_vars, clear_session_vars, _VAR_MAP

# Mark this as a cron session so the approval gate applies cron_mode.
# This MUST be a per-job ContextVar, not os.environ: the default
# deployment runs this ticker in-process inside the gateway, so a
# process-global marker persists after the first job and misroutes every
# concurrent interactive user's approval into the cron branch (a
# process-wide env var never cleared here would either hard-block their
# dangerous commands under cron_mode=deny or, worse, auto-approve them
# under cron_mode=approve). The set + clear both live inside the
# try/finally below so a raise before dispatch can't leave it set on a
# reused loop-thread context.

# Cron execution is an internal scheduler context, not a live inbound
# gateway message. Do not seed HERMES_SESSION_* contextvars from the
# stored ``origin`` (which is delivery routing metadata, not a sender
Expand Down Expand Up @@ -3105,6 +3111,9 @@ def run_job(
_prior_terminal_cwd = os.environ.get("TERMINAL_CWD", "_UNSET_")

_holds_cwd_write = _job_workdir is not None
# Predeclare the cron-marker token so the finally below can guard on it
# even if the try raises before the marker is set.
_cron_marker_token = None
if _holds_cwd_write:
_terminal_cwd_lock.acquire_write()
else:
Expand All @@ -3116,6 +3125,17 @@ def run_job(
# (every future job blocks on acquire_*); a leaked reader blocks all
# future writers. Acquire itself can't leak (it either blocks or returns).
try:
# Set the cron-session marker (see the ContextVar note above) as the
# first statement in the try so the finally below always restores it.
# It still precedes the copy_context() dispatch further down, so the
# pool thread running the conversation inherits it. Keep the token: the
# finally must reset() to the pre-job state rather than set("") because
# get_session_env treats any explicitly-set value, including "", as
# authoritative and never falls back to os.environ, so a leftover ""
# would misclassify a later standalone/env-marked cron read in this
# context as non-cron.
_cron_marker_token = _VAR_MAP["HERMES_CRON_SESSION"].set("1")

if _job_workdir:
os.environ["TERMINAL_CWD"] = _job_workdir
logger.info("Job '%s': using workdir %s", job_id, _job_workdir)
Expand Down Expand Up @@ -3759,10 +3779,16 @@ def _heartbeat_run_claim_if_due():
_terminal_cwd_lock.release_write()
else:
_terminal_cwd_lock.release_read()
# Clean up ContextVar session/delivery state for this job.
# clear_session_vars also clears _SESSION_CWD internally, so no
# separate clear_session_cwd() call is needed.
# Clean up ContextVar session/delivery state for this job. Reset the
# cron-session marker to its pre-job state (normally the _UNSET
# default) so a reused context is not treated as a cron session and a
# later env-marked read in this context can still fall back to
# os.environ. set("") would break that fallback. clear_session_vars
# also clears _SESSION_CWD internally, so no separate
# clear_session_cwd() call is needed.
clear_session_vars(_ctx_tokens)
if _cron_marker_token is not None:
_VAR_MAP["HERMES_CRON_SESSION"].reset(_cron_marker_token)
for _var_name in _cron_delivery_vars:
_VAR_MAP[_var_name].set("")
if _session_db:
Expand Down
8 changes: 8 additions & 0 deletions gateway/session_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,21 @@ def session_context_engaged() -> bool:
# propagates that into this contextvar at session-bind time.
_SESSION_ASYNC_DELIVERY: ContextVar = ContextVar("HERMES_SESSION_ASYNC_DELIVERY", default=_UNSET)

# Cron-session marker, set per-job in run_job() so the in-process gateway
# ticker does not leak "this is a cron context" into concurrent interactive
# gateway sessions on the same process. Task-local, unlike a process-global
# env var; the approval gate reads it via get_session_env (contextvar first,
# os.environ fallback for the standalone `hermes cron` process and tests).
_CRON_SESSION: ContextVar = ContextVar("HERMES_CRON_SESSION", default=_UNSET)

# Cron auto-delivery vars — set per-job in run_job() so concurrent jobs
# don't clobber each other's delivery targets.
_CRON_AUTO_DELIVER_PLATFORM: ContextVar = ContextVar("HERMES_CRON_AUTO_DELIVER_PLATFORM", default=_UNSET)
_CRON_AUTO_DELIVER_CHAT_ID: ContextVar = ContextVar("HERMES_CRON_AUTO_DELIVER_CHAT_ID", default=_UNSET)
_CRON_AUTO_DELIVER_THREAD_ID: ContextVar = ContextVar("HERMES_CRON_AUTO_DELIVER_THREAD_ID", default=_UNSET)

_VAR_MAP = {
"HERMES_CRON_SESSION": _CRON_SESSION,
"HERMES_SESSION_PLATFORM": _SESSION_PLATFORM,
"HERMES_SESSION_SOURCE": _SESSION_SOURCE,
"HERMES_SESSION_CHAT_ID": _SESSION_CHAT_ID,
Expand Down
26 changes: 26 additions & 0 deletions tests/cron/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,29 @@ def _default_cron_test_model(monkeypatch):
"""Pin a default HERMES_MODEL so cron run_job tests have a resolvable model."""
monkeypatch.setenv("HERMES_MODEL", "test-cron-default-model")
yield


@pytest.fixture(autouse=True)
def _reset_session_context_vars():
"""Reset every session-context ContextVar to its _UNSET default per test.

Cron tests drive the real ``run_job`` directly in the pytest context, and
its ``clear_session_vars`` finally intentionally pins every session var to
an explicit ``""`` (the gateway relies on that to suppress the
``os.environ`` fallback). In production the ticker confines that to a
per-job ``copy_context()``, but in a single-process test run it leaks into
later tests that rely on the env fallback: the approval timeout tests
resolve their session key through ``get_session_env`` and stop finding
their registered gateway callback after any cron test has run ``run_job``.
Restoring the defaults on both sides of each test keeps the cron suite
order-independent.
"""
from gateway.session_context import _VAR_MAP, _UNSET

def _reset_all():
for var in _VAR_MAP.values():
var.set(_UNSET)

_reset_all()
yield
_reset_all()
Loading
Loading