diff --git a/gateway/session_tui_side.py b/gateway/session_tui_side.py index 1c64539611..812f9781fd 100644 --- a/gateway/session_tui_side.py +++ b/gateway/session_tui_side.py @@ -66,7 +66,10 @@ def _side_scope(authority, policy, task_id): home = authority.profile_id profile = served_profile_name(home) if _is_routed_home(home) else '' with owner_scope(authority, hydrate_secrets=True), policy_scope(policy, authority=authority): - tokens = set_session_vars(session_key=task_id, session_id=task_id, cwd=policy.cwd, profile=profile) + # async_delivery=False: the side agent's answer is one ``*.complete`` event, and its bg_* session + # has no later turn to wake, so a background delegate_task joins in this run (#140569). + tokens = set_session_vars(session_key=task_id, session_id=task_id, cwd=policy.cwd, profile=profile, + async_delivery=False) try: # The session's workspace, as its own turns get it (#140665): mounted at /workspace in a # per-task container, or the session's own persistent workspace container (same digest). diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index f05679db55..b3fb704d9d 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1362,6 +1362,8 @@ class CLICommandsMixin(CLICommandsSessionToolsMixin): def produce(): from agent.vault_backends.unlock import set_code_prompt_callback, set_save_login_prompt_callback, set_unlock_prompt_callback + from gateway.session_context import declare_stateless_channel + declare_stateless_channel() # nothing drains a bg_* session's completions later: delegations join (#140569) set_sudo_password_callback(self._sudo_password_callback) set_approval_callback(self._approval_callback) set_unlock_prompt_callback(self._vault_unlock_callback) @@ -1402,10 +1404,8 @@ class CLICommandsMixin(CLICommandsSessionToolsMixin): response = _gt("model.error_prefix", error=result["error"]) return response finally: - # One agent per /bg task in a long-lived CLI process: close() - # is the owner boundary (memory shutdown, tool subprocesses, - # httpx clients); an unclosed side agent leaks all of them - # until the CLI exits (#50197). + # One agent per /bg task in a long-lived CLI: close() is the owner boundary (memory, + # tool subprocesses, httpx clients); unclosed, they leak until the CLI exits (#50197). with suppress(Exception): bg_agent.close() finally: diff --git a/tests/hermes_cli/test_cli_background_delegation.py b/tests/hermes_cli/test_cli_background_delegation.py new file mode 100644 index 0000000000..c2c3028d07 --- /dev/null +++ b/tests/hermes_cli/test_cli_background_delegation.py @@ -0,0 +1,51 @@ +"""Classic-CLI /bg: a background delegate_task joins inside the bg_* run (#140569). + +The /bg answer is printed once and nothing drains completions for its bg_* session, so a detached +child's result would sit in the ledger forever. +""" +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import cli as cli_module +from tests.hermes_cli.test_cli_approval_ui import _make_background_cli_stub + + +def test_background_task_delegation_runs_inline(monkeypatch): + import tools.delegate_tool as dt + + monkeypatch.setattr(dt, "_build_child_agent", lambda **kw: SimpleNamespace(_subagent_id="child")) + monkeypatch.setattr(dt, "_resolve_delegation_credentials", lambda *a, **kw: { + "model": "test", "provider": None, "base_url": None, "api_key": None, + "api_mode": None, "command": None, "args": None, + }) + monkeypatch.setattr(dt, "_run_single_child", lambda **kw: { + "task_index": 0, "status": "completed", "summary": "child work product", + "api_calls": 1, "duration_seconds": 0, "model": "test", "exit_reason": "completed", + }) + detached = MagicMock(return_value={"delegation_id": "detached"}) + monkeypatch.setattr("tools.async_delegation.dispatch_async_delegation_batch", detached) + seen = {} + + class FakeAgent: + def __init__(self, **kwargs): + self._print_fn = None + self.thinking_callback = None + + def run_conversation(self, **kwargs): + parent = SimpleNamespace(_delegate_depth=0, _subagent_id=None) + seen["tool"] = dt.delegate_task(goal="inspect workspace", background=True, parent_agent=parent) + return {"final_response": "done", "messages": []} + + def close(self): + pass + + cli = _make_background_cli_stub() + with patch("run_agent.AIAgent", FakeAgent), patch.object(cli_module, "_cprint"), \ + patch.object(cli_module, "ChatConsole"): + cli._handle_background_command("/bg inspect workspace") + for thread in list(cli._background_tasks.values()): + thread.join(timeout=10) + + detached.assert_not_called() + assert "child work product" in json.dumps(seen["tool"]) diff --git a/tools/delegate_tool_dispatch.py b/tools/delegate_tool_dispatch.py index 8d498e3b53..e66f43e7dc 100644 --- a/tools/delegate_tool_dispatch.py +++ b/tools/delegate_tool_dispatch.py @@ -229,8 +229,8 @@ _SYNC_FALLBACK_NOTES = { "no_async": ( "background=true is not available in this session — it cannot " "receive a detached subagent result after the turn ends (a " - "finite chat using -Q, --oneshot, or non-TTY stdio, `hermes -z`, a cron job, a Kanban " - "worker, or a stateless HTTP endpoint). The subagent(s) ran SYNCHRONOUSLY and the result is included above." + "finite chat using -Q, --oneshot, or non-TTY stdio, `hermes -z`, a /bg background task, a cron " + "job, a Kanban worker, or a stateless HTTP endpoint). The subagent(s) ran SYNCHRONOUSLY and the result is included above." ), "at_capacity": ( "The background delegation pool was at capacity (delegation.max_concurrent_children), so the subagent(s) ran "