fix: every /bg entry point joins its background delegations in the run

The gateway /bg fix above covered messaging chats only. The shared-owner
/bg (prompt.background, used by the CLI, TUI and Desktop) and the classic
local CLI /bg run the same detached bg_* side agent, and on main a
background delegate_task there also leaves a completed ledger row pending
forever: nothing wakes or drains a bg_* session. Their side agents now
declare the channel stateless, so the batch runs inline and the result is
in the one answer /bg reports. The sync-fallback note names /bg.

Refs #140569.

Co-authored-by: milnerrad <302432023+milnerrad@users.noreply.github.com>
This commit is contained in:
Tekniumandmilnerrad committed 2026-11-01 21:11:06 -08:00
1 parent 3d26ec8ee2
commit 2d0bf91f11
4 files changed
+61 -7

No files matched your search

+4 -1
View File
@@ -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).
+4 -4
View File
@@ -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:
@@ -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"])
+2 -2
View File
@@ -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 "