fix(gateway): deliver kanban/delegate wake-ups to api_server sessions

Wake-ups for kanban notifications and background delegation completions were
injected via handle_message() using a build_session_key()-derived key, which
can never match the raw X-Hermes-Session-Id key that api_server sessions run
under — so the wake landed in a session nobody was reading. On top of that,
ApiServerAdapter.send() reports failure without raising, and that was treated
as a successful delivery, so the notify cursor advanced past events that were
permanently lost; and background delegation was forced synchronous on
api_server since there was no way to wake the session afterward.

Fix: route wake-ups for non-push adapters through a self-post to
/v1/chat/completions with the original session id, treat non-raising send
failures as failures (rewind instead of advancing the cursor), and re-enable
background delegation whenever a session id is available to wake.

The origin session id is captured from the request-scoped api_server chat_id
binding rather than HERMES_SESSION_ID: constructing a child agent calls
set_current_session_id() with the subagent's internal id, clobbering that
variable right before dispatch would read it and misrouting the wake into
the subagent's own session.

Related: #56580, #64609, #53027, #63169, #56531, #50319, #64113
This commit is contained in:
Ian Ker-Seymer 2026-07-15 09:25:08 -04:00 committed by Teknium
parent 185b08a2eb
commit 246eacea7b
10 changed files with 979 additions and 7 deletions

View file

@ -487,6 +487,34 @@ def _prune_completed_locked() -> None:
_records.pop(rid, None)
def _current_origin_session_id() -> str:
"""Raw session id of the ORIGINATING api_server request, or ``""``.
The obvious source ``HERMES_SESSION_ID`` via ``get_session_env`` is
NOT safe to read at dispatch time: constructing a child agent
(``agent/agent_init.py``) calls ``set_current_session_id(child.session_id)``,
clobbering that ContextVar *and* ``os.environ`` with the subagent's
internal ``{timestamp}_{uuid}`` id moments before the dispatch code reads
it, so the completion wake would self-post into the subagent's own
(unread) session instead of the spawner's.
The request-scoped ``HERMES_SESSION_CHAT_ID`` binding survives child
construction: ``_bind_api_server_session`` binds ``chat_id`` to the raw
``X-Hermes-Session-Id``, and its only writer is ``set_session_vars``
``set_current_session_id`` never touches it. Gate on the platform: on
push platforms ``chat_id`` is a chat, not a session, so yield ``""``
there.
"""
try:
from gateway.session_context import get_session_env
if get_session_env("HERMES_SESSION_PLATFORM", "") != "api_server":
return ""
return get_session_env("HERMES_SESSION_CHAT_ID", "") or ""
except Exception:
return ""
def dispatch_async_delegation(
*,
goal: str,
@ -498,6 +526,7 @@ def dispatch_async_delegation(
parent_session_id: Optional[str] = None,
runner: Callable[[], Dict[str, Any]],
origin_ui_session_id: str = "",
origin_session_id: str = "",
interrupt_fn: Optional[Callable[[], None]] = None,
max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN,
) -> Dict[str, Any]:
@ -546,6 +575,7 @@ def dispatch_async_delegation(
"model": model,
"session_key": session_key,
"origin_ui_session_id": origin_ui_session_id,
"origin_session_id": origin_session_id,
"parent_session_id": parent_session_id,
"status": "running",
"dispatched_at": dispatched_at,
@ -666,6 +696,7 @@ def _push_completion_event(
# session; empty string => CLI (single-session) path.
"session_key": record.get("session_key", ""),
"origin_ui_session_id": record.get("origin_ui_session_id", ""),
"origin_session_id": record.get("origin_session_id", ""),
"parent_session_id": record.get("parent_session_id"),
"goal": record.get("goal", ""),
"context": record.get("context"),
@ -705,6 +736,7 @@ def dispatch_async_delegation_batch(
parent_session_id: Optional[str] = None,
runner: Callable[[], Dict[str, Any]],
origin_ui_session_id: str = "",
origin_session_id: str = "",
interrupt_fn: Optional[Callable[[], None]] = None,
max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN,
delegation_id: Optional[str] = None,
@ -746,6 +778,7 @@ def dispatch_async_delegation_batch(
"model": model,
"session_key": session_key,
"origin_ui_session_id": origin_ui_session_id,
"origin_session_id": origin_session_id,
"parent_session_id": parent_session_id,
"status": "running",
"dispatched_at": dispatched_at,
@ -846,6 +879,7 @@ def _finalize_batch(
"delegation_id": delegation_id,
"session_key": event_record.get("session_key", ""),
"origin_ui_session_id": event_record.get("origin_ui_session_id", ""),
"origin_session_id": event_record.get("origin_session_id", ""),
"parent_session_id": event_record.get("parent_session_id"),
"goal": event_record.get("goal", ""),
"goals": event_record.get("goals"),

View file

@ -2583,6 +2583,18 @@ def delegate_task(
_parent_tool_names = list(_model_tools._last_resolved_tool_names)
# Capture the ORIGINATING session's wake target BEFORE any child agent is
# constructed: _build_child_agent() -> AIAgent() -> agent_init calls
# set_current_session_id(child.session_id), which clobbers the
# HERMES_SESSION_ID ContextVar and os.environ with the subagent's internal
# id before the background-dispatch code below would read it. The
# request-scoped chat_id binding (the raw X-Hermes-Session-Id on
# api_server) is untouched by child construction, so read it here and
# thread it through the dispatch.
from tools.async_delegation import _current_origin_session_id
_origin_wake_sid = _current_origin_session_id()
# Build all child agents on the main thread (thread-safe construction)
# Wrapped in try/finally so the global is always restored even if a
# child build raises (otherwise _last_resolved_tool_names stays corrupted).
@ -2921,6 +2933,30 @@ def delegate_task(
_async_ok = async_delivery_supported()
except Exception:
_async_ok = True
_wake_sid = ""
if not _async_ok:
# The adapter itself cannot push, but if a raw session id is
# bound (the API server always binds one — see
# ApiServerAdapter._bind_api_server_session), gateway.wake can
# still reach the session by self-POSTing /v1/chat/completions
# with that id in X-Hermes-Session-Id once the batch completes.
# Only fall back to forced-sync execution when there is truly no
# session id to wake. Uses the origin captured before child
# construction (see _origin_wake_sid above) — reading
# HERMES_SESSION_ID here would return the subagent's internal id.
_wake_sid = _origin_wake_sid
if _wake_sid:
logger.info(
"delegate_task: async delivery unsupported on this "
"session, but a session id is bound (%s) — dispatching "
"in the background and waking the session via self-post "
"when it completes instead of forcing synchronous "
"execution.",
_wake_sid,
)
_async_ok = True
if not _async_ok:
logger.info(
"delegate_task: async delivery unsupported on this session "
@ -3013,6 +3049,7 @@ def delegate_task(
model=creds["model"],
session_key=_session_key,
origin_ui_session_id=_origin_ui_session_id,
origin_session_id=_wake_sid,
parent_session_id=_parent_session_id,
runner=_batch_runner,
interrupt_fn=_batch_interrupt,

View file

@ -1139,7 +1139,17 @@ def _handle_create(args: dict, **kw) -> str:
# Stamp the originating session id when the agent loop runs under
# ACP (which sets HERMES_SESSION_ID before invoking tools). NULL on
# CLI / dashboard paths and on legacy hosts that don't set the env.
session_id = args.get("session_id") or os.environ.get("HERMES_SESSION_ID")
# Prefer the request-scoped api_server origin binding: HERMES_SESSION_ID
# is clobbered with a subagent's internal id whenever a child agent is
# constructed in-process (agent_init calls set_current_session_id), which
# would stamp — and later wake — the wrong session.
from tools.async_delegation import _current_origin_session_id
session_id = (
args.get("session_id")
or _current_origin_session_id()
or os.environ.get("HERMES_SESSION_ID")
)
priority = args.get("priority")
# Resolve workspace. Workspace sharing is always explicit: omitted fields
# mean a fresh scratch workspace, even when a dispatcher-spawned worker