Skip to content
Closed
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
25 changes: 21 additions & 4 deletions gateway/session_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,13 +327,30 @@ def get_session_env(name: str, default: str = "") -> str:
return os.getenv(name, default)


def set_async_delivery_supported(supported: bool) -> None:
"""Declare whether the current context can deliver a background completion.

Standalone setter for callers that are not full gateway adapters and so
never go through ``set_session_vars`` — e.g. ``hermes -z`` oneshot runs,
whose process exits as soon as the single turn ends and therefore has no
channel left to route a detached subagent result or terminal watcher
notification back into the conversation. Binding ``False`` here makes
``delegate_task background=true`` fall back to synchronous execution and
``terminal`` refuse notify_on_complete/watch_patterns, instead of
orphaning work on a daemon thread that dies with the process.
"""
_SESSION_ASYNC_DELIVERY.set(bool(supported))


def async_delivery_supported() -> bool:
"""Whether the current session can deliver a background completion later.

Returns ``False`` only when the active session was explicitly bound by a
stateless adapter (the API server) that cannot route a notification back to
the agent after the turn ends. CLI, cron, and the real gateway platforms —
and any path that never bound the contextvar — return ``True``.
Returns ``False`` only when the active session was explicitly bound as
unable to route a notification back to the agent after the turn ends —
stateless adapters (the API server) via ``set_session_vars``, and oneshot
``hermes -z`` runs via ``set_async_delivery_supported``. Interactive CLI,
cron, and the real gateway platforms — and any path that never bound the
contextvar — return ``True``.

Tools that promise async delivery (``terminal`` notify_on_complete /
watch_patterns, ``delegate_task`` background=True) consult this before
Expand Down
14 changes: 14 additions & 0 deletions hermes_cli/oneshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,20 @@ def run_oneshot(
os.environ["HERMES_YOLO_MODE"] = "1"
os.environ["HERMES_ACCEPT_HOOKS"] = "1"

# Oneshot is a stateless one-turn channel: this process exits right after
# the response prints, so background delegations (daemon threads in THIS
# process) and terminal completion watchers would be orphaned mid-flight
# with no channel to deliver their results. Declare that up front so
# delegate_task downgrades background=true to synchronous execution and
# terminal refuses notify_on_complete — same contract as the stateless
# API server adapter.
try:
from gateway.session_context import set_async_delivery_supported

set_async_delivery_supported(False)
except Exception:
pass

# Redirect stderr AND stdout to devnull for the entire call tree.
# We'll print the final response to the real stdout at the end.
real_stdout = sys.stdout
Expand Down
77 changes: 77 additions & 0 deletions tests/hermes_cli/test_oneshot_async_delivery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Oneshot (`hermes -z`) must declare itself unable to deliver async results.

A oneshot process exits right after printing its single response. A
``delegate_task background=true`` dispatched during that turn runs on a daemon
thread in the SAME process, so it dies mid-flight and its row in
``async_delegations`` is orphaned at ``state: running`` forever (observed live
as deleg_3725e69f, deleg_4d9013c4, deleg_9dd323d8, deleg_a9651f0a).

The fix binds the session async-delivery contextvar to False in
``run_oneshot`` — the same capability gate the stateless API server adapter
uses — so ``delegate_task`` downgrades background batches to synchronous
execution and ``terminal`` refuses notify_on_complete watchers.
"""

import logging
import unittest
from unittest.mock import patch

from gateway.session_context import (
async_delivery_supported,
reset_session_vars,
set_async_delivery_supported,
)


class TestSetAsyncDeliverySupported(unittest.TestCase):
"""The standalone setter added for non-adapter callers (oneshot)."""

def tearDown(self):
reset_session_vars()

def test_set_false_is_unsupported(self):
set_async_delivery_supported(False)
self.assertFalse(async_delivery_supported())

def test_set_true_is_supported(self):
set_async_delivery_supported(True)
self.assertTrue(async_delivery_supported())

def test_reset_returns_to_default_supported(self):
set_async_delivery_supported(False)
reset_session_vars()
self.assertTrue(async_delivery_supported())


class TestRunOneshotBindsNoAsyncDelivery(unittest.TestCase):
"""run_oneshot must bind the capability BEFORE the agent turn starts."""

def tearDown(self):
reset_session_vars()
# run_oneshot silences the root logger for the whole process; undo so
# later tests keep their logging behavior.
logging.disable(logging.NOTSET)

def test_agent_turn_sees_async_delivery_unsupported(self):
seen = {}

def _fake_run_agent(prompt, **kwargs):
seen["async_ok"] = async_delivery_supported()
return "ok", {}

from hermes_cli import oneshot

with patch.object(oneshot, "_run_agent", side_effect=_fake_run_agent):
exit_code = oneshot.run_oneshot("hi")

self.assertEqual(exit_code, 0)
self.assertIn("async_ok", seen, "the stubbed agent turn never ran")
self.assertFalse(
seen["async_ok"],
"oneshot turn still believes it can deliver async results — "
"background delegations would be orphaned when the process exits",
)


if __name__ == "__main__":
unittest.main()
26 changes: 14 additions & 12 deletions tools/delegate_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -2808,13 +2808,14 @@ def _execute_and_aggregate() -> dict:
from tools.async_delegation import dispatch_async_delegation_batch
from tools.approval import get_current_session_key

# Stateless request/response sessions (the API server / WebUI path)
# cannot route a detached subagent result back to the agent after the
# turn ends — there is no persistent channel and the adapter's send()
# is a no-op, so a background dispatch would silently never re-enter the
# conversation (issue #10760). Fall back to SYNCHRONOUS execution: the
# work still runs and its result returns in this same response, which is
# strictly better than a handle that never resolves. Mirrors the
# Stateless one-turn sessions (the API server / WebUI path, and
# ``hermes -z`` oneshot runs) cannot route a detached subagent result
# back to the agent after the turn ends — there is no persistent
# channel (or the whole process exits, killing the daemon worker), so
# a background dispatch would silently never re-enter the conversation
# (issue #10760). Fall back to SYNCHRONOUS execution: the work still
# runs and its result returns in this same response, which is strictly
# better than a handle that never resolves. Mirrors the
# pool-at-capacity inline fallback below.
try:
from gateway.session_context import async_delivery_supported
Expand All @@ -2824,15 +2825,16 @@ def _execute_and_aggregate() -> dict:
if not _async_ok:
logger.info(
"delegate_task: async delivery unsupported on this session "
"(stateless HTTP API); running the batch synchronously instead."
"(stateless one-turn channel); running the batch synchronously "
"instead."
)
_sync_result = _execute_and_aggregate()
if isinstance(_sync_result, dict):
_sync_result["note"] = (
"background=true is not available on this endpoint (stateless "
"HTTP API — no channel to deliver a detached subagent result "
"after the turn ends), so the subagent(s) ran SYNCHRONOUSLY and "
"the result is included above."
"background=true is not available in this session (stateless "
"one-turn channel — no way to deliver a detached subagent "
"result after the turn ends), so the subagent(s) ran "
"SYNCHRONOUSLY and the result is included above."
)
return json.dumps(_sync_result, ensure_ascii=False)

Expand Down