"""Context-local state for delegate_task child execution. A Hermes process may itself be a Kanban dispatcher worker with HERMES_KANBAN_* in os.environ. delegate_task children (in-process) and cron jobs fired via ``cronjob(action="run")`` are NOT dispatcher-owned, so identity gates must fail closed for them without mutating the process-global environment. """ from __future__ import annotations import os from contextlib import contextmanager from contextvars import ContextVar, Token from typing import Iterator, Mapping, MutableMapping _DELEGATED_CHILD_CONTEXT: ContextVar[bool] = ContextVar("hermes_delegated_child_context", default=False) # Any in-process execution that is NOT the dispatcher-owned worker (cron jobs). # Kept separate from _DELEGATED_CHILD_CONTEXT so delegate_task-specific # behaviour (subprocess env scrubbing, its error strings) is unchanged. _NON_DISPATCHER_OWNED_CONTEXT: ContextVar[bool] = ContextVar("hermes_non_dispatcher_owned_context", default=False) DELEGATED_CHILD_ENV_MARKER = "HERMES_DELEGATED_CHILD_CONTEXT" KANBAN_ENV_KEYS: tuple[str, ...] = ( "HERMES_KANBAN_TASK", "HERMES_KANBAN_RUN_ID", "HERMES_KANBAN_WORKSPACE", "HERMES_KANBAN_WORKSPACES_ROOT", "HERMES_KANBAN_CLAIM_LOCK", "HERMES_KANBAN_BOARD", "HERMES_KANBAN_DB", ) @contextmanager def delegated_child_context(session_id: str | None = None) -> Iterator[None]: """Mark child execution and isolate its task-local session identity. Even a context entered without an id must restore the parent's session ContextVar (child construction calls ``set_current_session_id``). """ token = _DELEGATED_CHILD_CONTEXT.set(True) try: # Lazy: session_context calls is_delegated_child_context(). from gateway.session_context import scoped_current_session_id with scoped_current_session_id(session_id): yield finally: _DELEGATED_CHILD_CONTEXT.reset(token) def is_delegated_child_context() -> bool: """Return True while code is running for a delegate_task child.""" return bool(_DELEGATED_CHILD_CONTEXT.get()) def enter_non_dispatcher_owned_context() -> Token[bool]: """Token form of :func:`non_dispatcher_owned_context` for long try/finally scopes.""" return _NON_DISPATCHER_OWNED_CONTEXT.set(True) def exit_non_dispatcher_owned_context(token: Token[bool]) -> None: """Restore the flag saved by :func:`enter_non_dispatcher_owned_context`.""" _NON_DISPATCHER_OWNED_CONTEXT.reset(token) @contextmanager def non_dispatcher_owned_context() -> Iterator[None]: """Mark in-process execution that does NOT own the dispatcher's Kanban task. Without it a cron agent run inside a worker is misread as that worker (kanban toolset force-added, ``kanban_complete`` defaulting to its task). ContextVar-scoped rather than clearing os.environ, which the worker's claim heartbeat and concurrent readers share. """ token = enter_non_dispatcher_owned_context() try: yield finally: exit_non_dispatcher_owned_context(token) def is_dispatcher_owned_worker_context() -> bool: """The single predicate every ``HERMES_KANBAN_*`` identity gate should use.""" return not (_DELEGATED_CHILD_CONTEXT.get() or _NON_DISPATCHER_OWNED_CONTEXT.get()) def is_delegated_child_process_context() -> bool: """Return True in this process or a subprocess spawned by a child.""" return bool(_DELEGATED_CHILD_CONTEXT.get()) or bool(os.environ.get(DELEGATED_CHILD_ENV_MARKER)) def scrub_kanban_env(env: Mapping[str, str] | MutableMapping[str, str]) -> dict[str, str]: """Return *env* with dispatcher-only Kanban variables removed.""" cleaned = dict(env) for key in KANBAN_ENV_KEYS: cleaned.pop(key, None) cleaned[DELEGATED_CHILD_ENV_MARKER] = "1" return cleaned def delegated_child_subprocess_env( env: Mapping[str, str] | MutableMapping[str, str] | None = None, ) -> dict[str, str] | None: """Return an env override only when delegated-child lineage must cross fork. Preserves ``env=None`` inherit semantics for non-delegated calls; in a child, materializes a scrubbed env carrying the lineage marker. """ if not is_delegated_child_process_context(): return None if env is None else dict(env) return scrub_kanban_env(os.environ if env is None else env)