fix(cron): don't let a cron job inherit a kanban worker's dispatcher identity
A kanban worker that fires a cron job in-process no longer leaks its task
identity into the cron agent.
The worker is a normal `hermes chat -q` CLI agent whose default toolset
includes `cronjob`, running with HERMES_KANBAN_TASK legitimately set in its
own environment. `cronjob(action="run")` calls run_one_job() -> run_job()
in that same process, so the cron AIAgent was misidentified as that worker:
kanban toolset force-added, kanban-worker protocol injected into its system
prompt, and kanban_complete defaulting task_id to $HERMES_KANBAN_TASK --
letting an unrelated cron job close the worker's task and overwrite real
results.
Fixed with a ContextVar (`non_dispatcher_owned_context`), not by clearing
os.environ. The env is process-global and shared with three concurrent
readers that all need the real values:
* the worker's own claim heartbeat -- run_agent._touch_activity ->
heartbeat_current_worker_from_env reads TASK/CLAIM_LOCK/RUN_ID, and the
cron-run heartbeat thread drives it every 10s. Clearing them silently
no-ops the heartbeat, so after DEFAULT_CLAIM_TTL_SECONDS (15 min) the
dispatcher reclaims a task whose worker is still alive and re-dispatches
it -- the same duplicate-work failure from the other direction.
* the gateway's kanban watchers, which do their own HERMES_KANBAN_BOARD
save/restore around a slow decompose_task() LLM call.
* concurrent cron jobs, which take a *shared* read lock
(_terminal_cwd_lock.acquire_read) and so interleave: job A clears, job B
snapshots empty, A restores, B clears and its restore no-ops -- the
worker's identity is destroyed permanently.
`is_dispatcher_owned_worker_context()` is now the single predicate every
HERMES_KANBAN_* identity gate consults before trusting those vars. It also
closes a pre-existing gap in agent/skill_utils.py, which read the vars
without consulting the delegate_task ContextVar at all; the `kanban` verdict
additionally bypasses _ENV_DETECT_CACHE, since a context-dependent answer
must not be memoized process-wide.
HERMES_KANBAN_BOARD/DB/WORKSPACES_ROOT are left untouched, so the #20074
board pin and the dispatcher's path overrides keep working.
Tests: 18 new, including thread-isolation, concurrent-cron-jobs, and an AST
invariant over _default_spawn that fails if the dispatcher gains a var that
is neither identity-gated nor explicitly classified behaviour-only. All six
mutations are caught, including one that reintroduces the os.environ clear.
tests/cron/ + kanban suites 440 passed; model_tools/skill_utils/boards 63
passed; ruff clean.
Reported and diagnosed by Geoff Friesen (#78961), who identified the symptom
and the exact gating mechanism.
Co-authored-by: Geoff Friesen <gfriesen1@users.noreply.github.com>
This commit is contained in:
+19
-3
@@ -289,10 +289,12 @@ _ENV_DETECT_CACHE: Dict[str, bool] = {}
|
||||
def _detect_environment(env: str) -> bool:
|
||||
"""Return True when the named runtime environment is currently active.
|
||||
|
||||
Cached per process. Unknown env names return True (fail-open: never hide a
|
||||
skill because of a tag we don't understand).
|
||||
Cached per process, EXCEPT ``kanban``: that verdict is context-dependent
|
||||
(a delegate_task child or an in-process cron job sees the worker's
|
||||
HERMES_KANBAN_* vars without owning them), so caching it process-wide would
|
||||
freeze whichever context asked first and leak it to the others.
|
||||
"""
|
||||
if env in _ENV_DETECT_CACHE:
|
||||
if env != "kanban" and env in _ENV_DETECT_CACHE:
|
||||
return _ENV_DETECT_CACHE[env]
|
||||
|
||||
result = True
|
||||
@@ -304,6 +306,20 @@ def _detect_environment(env: str) -> bool:
|
||||
# gate on (``tools/kanban_tools.py``) so the offer filter agrees with
|
||||
# tool availability.
|
||||
if os.getenv("HERMES_KANBAN_TASK") or os.getenv("HERMES_KANBAN_BOARD"):
|
||||
# ...but only when this execution actually owns the dispatcher's
|
||||
# task. A delegate_task child or a cron job fired in-process from a
|
||||
# worker sees the worker's vars without being that worker.
|
||||
try:
|
||||
from agent.delegation_context import (
|
||||
is_dispatcher_owned_worker_context,
|
||||
)
|
||||
|
||||
_owns_dispatcher_task = is_dispatcher_owned_worker_context()
|
||||
except Exception:
|
||||
_owns_dispatcher_task = True
|
||||
else:
|
||||
_owns_dispatcher_task = False
|
||||
if _owns_dispatcher_task:
|
||||
result = True
|
||||
else:
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user