Skip to content
Open
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
3 changes: 1 addition & 2 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -6368,9 +6368,8 @@ def _perform_api_call(next_api_kwargs):
})
agent._session_messages = messages
logger.info(
"kanban stop-loop nudge issued (attempt %d) task=%s",
"kanban stop-loop nudge issued (attempt %d)",
agent._kanban_stop_nudges,
os.environ.get("HERMES_KANBAN_TASK", ""),
)
agent._emit_status(
"⚠️ Kanban worker tried to exit without "
Expand Down
29 changes: 29 additions & 0 deletions agent/delegation_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
default=False,
)

_KANBAN_WORKER_OWNER: ContextVar[bool] = ContextVar(
"hermes_kanban_worker_owner",
default=False,
)

DELEGATED_CHILD_ENV_MARKER = "HERMES_DELEGATED_CHILD_CONTEXT"

KANBAN_ENV_KEYS: tuple[str, ...] = (
Expand Down Expand Up @@ -54,6 +59,30 @@ def is_delegated_child_process_context() -> bool:
)


def set_kanban_worker_owner() -> None:
"""Mark the current execution context as a verified Kanban worker owner.

Called once at the CLI boundary after verifying that the dispatcher
query marker matches ``HERMES_KANBAN_TASK``. Lifecycle code consumes
:func:`is_kanban_worker_owner` instead of re-reading ``os.environ``,
so a nested ``hermes chat`` subprocess that inherited
``HERMES_KANBAN_*`` env vars is never treated as the parent owner.
"""
_KANBAN_WORKER_OWNER.set(True)


def is_kanban_worker_owner() -> bool:
"""Return True only when this process has verified dispatcher ownership.

Unlike ``os.environ.get("HERMES_KANBAN_TASK")``, this ContextVar
cannot be inherited by a child subprocess. A nested ``hermes chat``
that inherited ``HERMES_KANBAN_*`` env vars will get ``False`` here
because the verification step in the CLI entry point won't match the
arbitrary query.
"""
return bool(_KANBAN_WORKER_OWNER.get())


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)
Expand Down
15 changes: 11 additions & 4 deletions agent/kanban_stop.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,21 @@
def kanban_stop_nudge_enabled() -> bool:
"""Return whether the kanban stop-guard is active for this process.

On when ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), unless
``HERMES_KANBAN_STOP_NUDGE`` explicitly disables it.
On when the process has been verified as a dispatcher-owned kanban
worker at the CLI boundary, unless ``HERMES_KANBAN_STOP_NUDGE``
explicitly disables it. Uses the ContextVar instead of raw env so a
nested ``hermes chat`` subprocess that inherited ``HERMES_KANBAN_*``
env vars is not treated as the parent worker (#70809).
"""
env = os.environ.get("HERMES_KANBAN_STOP_NUDGE")
if env is not None and env.strip().lower() in {"0", "false", "no", "off"}:
return False
task = (os.environ.get("HERMES_KANBAN_TASK") or "").strip()
return bool(task)
try:
from agent.delegation_context import is_kanban_worker_owner
return is_kanban_worker_owner()
except Exception:
task = (os.environ.get("HERMES_KANBAN_TASK") or "").strip()
return bool(task)


def _tool_call_name(tc: Any) -> str:
Expand Down
21 changes: 15 additions & 6 deletions agent/skill_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,13 +246,22 @@ def _detect_environment(env: str) -> bool:

result = True
if env == "kanban":
# Kanban is "active" either as a dispatcher-spawned worker (the
# dispatcher sets ``HERMES_KANBAN_TASK`` / ``HERMES_KANBAN_BOARD`` in the
# worker env) or as an orchestrator profile that has opted into the
# kanban toolset. Mirror the same signals the kanban tools themselves
# gate on (``tools/kanban_tools.py``) so the offer filter agrees with
# Kanban is "active" either as a dispatcher-spawned worker (verified
# at the CLI boundary and stored in a ContextVar) or as an
# orchestrator profile that has opted into the kanban toolset.
# Mirror the same signals the kanban tools themselves 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"):
# Uses ContextVar instead of raw env so a nested subprocess that
# inherited ``HERMES_KANBAN_*`` env vars is not treated as a
# kanban worker (#70809).
try:
from agent.delegation_context import is_kanban_worker_owner

_is_owner = is_kanban_worker_owner()
except Exception:
_is_owner = bool(os.getenv("HERMES_KANBAN_TASK"))
if _is_owner or os.getenv("HERMES_KANBAN_BOARD"):
result = True
else:
try:
Expand Down
11 changes: 10 additions & 1 deletion agent/turn_finalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,17 @@ def finalize_turn(
# We route through ``_record_task_failure(outcome="timed_out")``
# rather than ``kanban_block`` so this counts toward the dispatcher's
# consecutive-failure circuit breaker (#29747 gap 2).
#
# Use the verified ContextVar instead of raw env so a nested
# ``hermes chat`` subprocess that inherited HERMES_KANBAN_* env
# vars never records a failure on the parent's task (#70809).
_kanban_task = os.environ.get("HERMES_KANBAN_TASK")
if _kanban_task:
try:
from agent.delegation_context import is_kanban_worker_owner as _is_owner
_is_kanban_worker = _is_owner()
except Exception:
_is_kanban_worker = bool(_kanban_task)
if _kanban_task and _is_kanban_worker:
try:
from hermes_cli import kanban_db as _kb
_conn = _kb.connect()
Expand Down
108 changes: 82 additions & 26 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -16031,6 +16031,15 @@ def _run_kanban_goal_loop_q(cli: "HermesCLI", first_response: str) -> None:
"""
import os as _os

# Gate on ContextVar so a nested subprocess that inherited
# HERMES_KANBAN_* env vars never enters the goal loop (#70809).
try:
from agent.delegation_context import is_kanban_worker_owner as _goal_owner
if not _goal_owner():
return
except Exception:
pass

task_id = (_os.environ.get("HERMES_KANBAN_TASK") or "").strip()
if not task_id:
return
Expand Down Expand Up @@ -16368,7 +16377,15 @@ def _signal_handler_q(signum, frame):
# first so the final debug trace isn't lost; SIGALRM deadman guards
# the flush against any rare blocking-I/O case (the reporter measured
# flush in <1ms; the alarm is a failsafe, not the common path).
if os.environ.get("HERMES_KANBAN_TASK"):
# Use the verified ContextVar instead of raw env so a nested
# subprocess that inherited HERMES_KANBAN_* env vars never
# triggers os._exit(0) on signal (#70809).
try:
from agent.delegation_context import is_kanban_worker_owner as _sig_owner
_is_kanban = _sig_owner()
except Exception:
_is_kanban = bool(os.environ.get("HERMES_KANBAN_TASK"))
if _is_kanban:
try:
import signal as _sig_mod
if hasattr(_sig_mod, "SIGALRM"):
Expand Down Expand Up @@ -16405,43 +16422,73 @@ def _signal_handler_q(signum, frame):
sys.exit(1)
try:
query, single_query_images = _collect_query_images(query, image)

# ── Derive Kanban worker ownership at the CLI boundary (#70809) ──
# The dispatcher spawns workers with:
# hermes chat -q "work kanban task <task_id>"
# A nested ``hermes chat`` subprocess inherits HERMES_KANBAN_* env
# vars but its query won't match this marker — it is NOT the
# dispatcher-owned worker. Verify once here and store the result
# in a ContextVar so lifecycle code (heartbeat, goal loop, signal
# handler, stop-nudge) reads the verified identity instead of
# re-reading os.environ.
_raw_query = isinstance(query, str) and query.strip()
_tid = os.environ.get("HERMES_KANBAN_TASK", "").strip()
if _raw_query and _tid:
try:
from agent.delegation_context import set_kanban_worker_owner as _set_owner

if _raw_query == f"work kanban task {_tid}":
_set_owner()
except Exception:
pass

# Kanban workers spawn with ``hermes chat -q "work kanban task <id>"``;
# the actual task description lives in the task body. Mirror the
# gateway/CLI behaviour for inbound images by scanning the body for
# local image paths and http(s) image URLs and attaching them to the
# worker's first turn. Without this, users who paste a screenshot
# path or URL into a kanban task body never get it routed to the
# model's vision input.
# Guard with the ContextVar so a nested subprocess with inherited
# env vars doesn't try to read the kanban DB (#70809).
single_query_image_urls: list[str] = []
_kanban_task_id = os.environ.get("HERMES_KANBAN_TASK", "").strip()
if _kanban_task_id:
try:
from hermes_cli import kanban_db as _kb
from agent.image_routing import extract_image_refs as _extract_refs
from agent.delegation_context import is_kanban_worker_owner as _is_owner

_conn = _kb.connect()
try:
_task = _kb.get_task(_conn, _kanban_task_id)
finally:
_should_enrich = _is_owner()
except Exception:
_should_enrich = True
if _should_enrich:
try:
_conn.close()
except Exception:
pass
_body = getattr(_task, "body", "") if _task is not None else ""
if _body:
_kb_paths, _kb_urls = _extract_refs(_body)
if _kb_paths:
# Dedupe against any --image the user already passed.
_seen = {str(p) for p in single_query_images}
for _p in _kb_paths:
if _p not in _seen:
_seen.add(_p)
single_query_images.append(Path(_p))
if _kb_urls:
single_query_image_urls.extend(_kb_urls)
except Exception as _exc:
# Best-effort enrichment; never block worker startup on it.
logger.debug("kanban image-ref extraction failed: %s", _exc)
from hermes_cli import kanban_db as _kb
from agent.image_routing import extract_image_refs as _extract_refs

_conn = _kb.connect()
try:
_task = _kb.get_task(_conn, _kanban_task_id)
finally:
try:
_conn.close()
except Exception:
pass
_body = getattr(_task, "body", "") if _task is not None else ""
if _body:
_kb_paths, _kb_urls = _extract_refs(_body)
if _kb_paths:
# Dedupe against any --image the user already passed.
_seen = {str(p) for p in single_query_images}
for _p in _kb_paths:
if _p not in _seen:
_seen.add(_p)
single_query_images.append(Path(_p))
if _kb_urls:
single_query_image_urls.extend(_kb_urls)
except Exception as _exc:
# Best-effort enrichment; never block worker startup on it.
logger.debug("kanban image-ref extraction failed: %s", _exc)
if quiet:
# Quiet mode: suppress banner, spinner, tool previews.
# Only print the final response and parseable session info.
Expand Down Expand Up @@ -16584,7 +16631,16 @@ def _signal_handler_q(signum, frame):
_exit_code = 0
if isinstance(result, dict) and result.get("failed"):
_exit_code = 1
if os.environ.get("HERMES_KANBAN_TASK") and result.get(
# Use the verified ContextVar instead of raw env so
# a nested subprocess with inherited HERMES_KANBAN_*
# env vars doesn't get the rate-limit exit code on
# someone else's task (#70809).
try:
from agent.delegation_context import is_kanban_worker_owner as _exit_owner
_exit_wk = _exit_owner()
except Exception:
_exit_wk = bool(os.environ.get("HERMES_KANBAN_TASK"))
if _exit_wk and result.get(
"failure_reason"
) in ("rate_limit", "billing"):
try:
Expand Down
20 changes: 18 additions & 2 deletions hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,15 @@ def _is_kanban_worker_env_gate(item: dict) -> bool:
"""Return True when Kanban is unavailable only because this is not a worker process."""
if item.get("name") != "kanban":
return False
if os.environ.get("HERMES_KANBAN_TASK"):
# Use ContextVar so a nested subprocess with inherited env vars
# is not treated as a kanban worker (#70809).
try:
from agent.delegation_context import is_kanban_worker_owner

_is_wk = is_kanban_worker_owner()
except Exception:
_is_wk = bool(os.environ.get("HERMES_KANBAN_TASK"))
if _is_wk:
return False

tools = item.get("tools") or []
Expand All @@ -129,7 +137,15 @@ def _is_kanban_worker_env_gate(item: dict) -> bool:

def _doctor_tool_availability_detail(toolset: str) -> str:
"""Optional explanatory suffix for toolsets whose doctor status needs context."""
if toolset == "kanban" and not os.environ.get("HERMES_KANBAN_TASK"):
# Use ContextVar so a nested subprocess with inherited env vars
# is not treated as a kanban worker (#70809).
try:
from agent.delegation_context import is_kanban_worker_owner

_is_wk = is_kanban_worker_owner()
except Exception:
_is_wk = bool(os.environ.get("HERMES_KANBAN_TASK"))
if toolset == "kanban" and not _is_wk:
return "(runtime-gated; loaded only for dispatcher-spawned workers)"
return ""

Expand Down
7 changes: 7 additions & 0 deletions hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -2105,6 +2105,13 @@ def _cmd_attach_rm(args: argparse.Namespace) -> int:


def _worker_run_id_for(task_id: str) -> Optional[int]:
# Use ContextVar to verify ownership (#70809).
try:
from agent.delegation_context import is_kanban_worker_owner
if not is_kanban_worker_owner():
return None
except Exception:
pass
if os.environ.get("HERMES_KANBAN_TASK") != task_id:
return None
raw = os.environ.get("HERMES_KANBAN_RUN_ID")
Expand Down
19 changes: 17 additions & 2 deletions model_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ def _is_delegated_child_context() -> bool:
return False


def _is_kanban_worker_owner() -> bool:
"""Return True when this process has verified dispatcher ownership.

Uses the ContextVar set at the CLI boundary rather than raw env so a
nested ``hermes chat`` subprocess that inherited ``HERMES_KANBAN_*``
env vars is never treated as the parent worker (#70809).
"""
try:
from agent.delegation_context import is_kanban_worker_owner

return is_kanban_worker_owner()
except Exception:
return bool(os.environ.get("HERMES_KANBAN_TASK"))


# =============================================================================
# Async Bridging (single source of truth -- used by registry.dispatch too)
# =============================================================================
Expand Down Expand Up @@ -330,7 +345,7 @@ def get_tool_definitions(
frozenset(disabled_toolsets) if disabled_toolsets else None,
registry._generation,
cfg_fp,
bool(os.environ.get("HERMES_KANBAN_TASK")),
bool(_is_kanban_worker_owner()),
bool(skip_tool_search_assembly),
_is_delegated_child_context(),
)
Expand Down Expand Up @@ -377,7 +392,7 @@ def _compute_tool_definitions(
if enabled_toolsets is not None:
effective_enabled_toolsets = list(enabled_toolsets)
if (
os.environ.get("HERMES_KANBAN_TASK")
_is_kanban_worker_owner()
and not _is_delegated_child_context()
and "kanban" not in effective_enabled_toolsets
):
Expand Down
10 changes: 9 additions & 1 deletion run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3432,7 +3432,15 @@ def _touch_activity(self, desc: str) -> None:
"""
self._last_activity_ts = time.time()
self._last_activity_desc = desc
if os.environ.get("HERMES_KANBAN_TASK"):
# Use the verified ContextVar instead of raw env so a nested
# ``hermes chat`` subprocess that inherited HERMES_KANBAN_* env
# vars never heartbeats as the parent owner (#70809).
try:
from agent.delegation_context import is_kanban_worker_owner as _is_owner
_should_heartbeat = _is_owner()
except Exception:
_should_heartbeat = bool(os.environ.get("HERMES_KANBAN_TASK"))
if _should_heartbeat:
try:
from tools.kanban_tools import heartbeat_current_worker_from_env
heartbeat_current_worker_from_env()
Expand Down
Loading
Loading