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

@ -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,