Files
hermes-agent/tools/terminal_tool_background.py
teknium1 704f0b1c91 fix(gateway): background completions reach the chat while the launching turn is still running
`terminal(background=true, notify_on_complete=true)` appended its watcher descriptor to
`process_registry.pending_watchers`, which only the post-turn hooks drain. A process that
finished while the turn that launched it was still running (an agent sleep-polling for
hours) had no watcher task at all: the completion_queue entry sat inert, nothing was
injected, and the chat stayed mute until that turn ended (#112033).

- `_register_completion_watcher` arms the watcher on the live gateway loop at registration
  (`GatewayRunner.arm_process_watcher`, via the existing `_gateway_runner_ref` /
  `_gateway_loop` seam that send_message and cron already use); `pending_watchers` stays
  the fallback while the gateway is not serving (checkpoint recovery at startup, shutdown).
- The agent-notify branch of `_run_process_watcher` keeps its design (the agent's next turn
  is the user-facing report) but, when the launching turn is still active at process exit,
  the injection only queues a follow-up — so the concise receipt is sent to the chat right
  away instead of never. The busy check is taken before injection because the injected turn
  itself installs the adapter's session guard.

Live probe (real process, real GatewayRunner loop, fake telegram adapter, busy session):
before — pending_watchers=1 after exit, 0 watcher tasks, 0 injections, 0 receipts;
after — pending_watchers=0, watcher task armed at launch, 1 injection, 1 concise receipt.
Control (idle session): 1 injection, 0 receipts, unchanged.

Slimmer redo of #112038 by @KoNit-K: same two gaps closed, without a second scheduler
registry / loop attribute on ProcessRegistry and GatewayRunner.

Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
2026-09-15 18:58:30 -07:00

257 lines
13 KiB
Python

"""Background-process launch path: ``terminal(background=true)`` spawns a tracked
process via the process registry (Popen locally, ``env.execute`` in a sandbox),
stamps gateway routing metadata for completion / watch-pattern notifications and
returns the JSON result. Lazy ``tools.terminal_tool`` imports keep the origin's
monkeypatch points authoritative.
"""
import json
import logging
import sys
from typing import Any, List, Optional
logger = logging.getLogger("tools.terminal_tool")
# A silent background process (no notify_on_complete / watch_patterns) is right
# only for servers/watchers; for bounded tasks the agent almost always wanted a
# notification and forgot the flag, so nudge it (cheap false positive).
_SILENT_BACKGROUND_HINT = (
'background=true without notify_on_complete=true means this process runs SILENTLY — you '
'will not be told when it exits. If this is a bounded task (test suite, build, CI poller, '
'deploy, anything with a defined end), you almost certainly wanted notify_on_complete=true '
'so the system pings you on exit. Re-launch with notify_on_complete=true, or call '
"process(action='poll') / process(action='wait') yourself to learn the outcome. Only "
'ignore this hint for genuine long-lived processes that never exit (servers, watchers, '
'daemons).'
)
# Homebrewed CI pollers built on `gh pr view --json statusCheckRollup` or
# `gh pr checks | jq` fail silently in known ways (block-buffered stdout never
# reaches capture, jq null-key edge cases exit the loop, conclusion-vs-status
# confusion declares all-green early, TTY-only banners never appear piped).
# Detector is deliberately narrow: the canonical column-2 awk poller is fine.
_HOMEBREW_CI_POLLER_HINT = (
'This looks like a homebrewed CI poller built from `gh pr view --json statusCheckRollup` '
'and/or `gh pr checks | jq`. That shape has burned us repeatedly in hermes-agent dev work '
'(PRs #31329, #31448, #31695, #31709, #31745, #32264, #33131) — stdout buffering kills '
'output capture, jq null-key edge cases silently exit the loop, conclusion-vs-status field '
'confusion exits early with bogus all-green verdicts, TTY-only summary banners never '
'appear when piped. Use the canonical snippets in the green-ci-policy skill instead: the '
'exit-code-driven `gh pr checks $PR >/dev/null` (rc 0 = green, 8 = pending, else fail) for '
'exit-on-first-fail behavior, or the column-2 awk-on-tabs poller (`awk -F"\\t" '
'"$2==\\"pending\\""`) for sharded matrices. Load '
"skill_view(name='github/hermes-agent-dev', file_path='references/green-ci-policy.md') for "
'the verbatim snippets. If you must roll a custom loop with rich structured output, write '
"each tick to a known file (`tee -a /tmp/ci.log`) and rely on `process(action='log')` to "
'read THAT file — do not rely on background-process stdout capture for line-buffered shell '
'loops.'
)
_ASYNC_UNSUPPORTED_NOTE = (
'notify_on_complete / watch_patterns are not available in this session — it cannot receive '
'an async completion after the turn ends (a one-shot runner such as `hermes -z`, a cron '
'job, a Kanban worker, or a stateless HTTP endpoint). The process is running in the '
"background; retrieve its result with process(action='poll') or process(action='wait')."
)
# proc_session attribute -> HERMES_SESSION_* env var carrying it.
_ROUTING_FIELDS = (
("watcher_chat_id", "HERMES_SESSION_CHAT_ID"),
("watcher_user_id", "HERMES_SESSION_USER_ID"),
("watcher_user_name", "HERMES_SESSION_USER_NAME"),
("watcher_thread_id", "HERMES_SESSION_THREAD_ID"),
("watcher_message_id", "HERMES_SESSION_MESSAGE_ID"),
# The spawning conversation's session-db id lets the gateway's completion
# pre-flight drop the notification if the user closed this session (/new)
# before the process finished, instead of injecting it into the NEW one.
("parent_session_id", "HERMES_SESSION_ID"),
)
def _looks_like_homebrew_ci_poller(command: str) -> bool:
has_gh = "gh pr view" in command or "gh pr checks" in command
has_jq = " jq " in command or "| jq" in command or "$(jq" in command
# `gh pr checks` doesn't emit JSON, so piping it to jq is confused intent.
return "statusCheckRollup" in command or (has_gh and has_jq)
def _stamp_gateway_routing(proc_session, get_session_env) -> None:
"""Copy the spawning chat's routing metadata onto the process session so
completion / watch notifications reach the right chat/thread."""
platform = get_session_env("HERMES_SESSION_PLATFORM", "")
if not platform:
return
proc_session.watcher_platform = platform
for attr, var in _ROUTING_FIELDS:
setattr(proc_session, attr, get_session_env(var, ""))
def _spawn(process_registry, *, env, env_type, command, cwd, effective_task_id, task_id,
session_key, effective_pty):
common = dict(command=command, cwd=cwd, task_id=effective_task_id,
owner_task_id=task_id or effective_task_id, session_key=session_key)
if env_type == "local":
return process_registry.spawn_local(
env_vars=env.env if hasattr(env, 'env') else None, use_pty=effective_pty, **common)
return process_registry.spawn_via_env(env=env, **common)
def _apply_async_support(proc_session, result_data, notify_on_complete, watch_patterns):
"""Finite sessions (stateless HTTP, one-shot Kanban workers) can't route a
completion back after the turn ends: drop the flags and tell the agent to
poll. Otherwise stamp gateway routing. Returns (notify, watch_patterns)."""
if not (notify_on_complete or watch_patterns):
return notify_on_complete, watch_patterns
from gateway.session_context import async_delivery_supported, get_session_env
if async_delivery_supported():
_stamp_gateway_routing(proc_session, get_session_env)
return notify_on_complete, watch_patterns
result_data["notify_on_complete"] = False
result_data["notify_unsupported"] = _ASYNC_UNSUPPORTED_NOTE
logger.info("background proc %s: async delivery unsupported on this "
"session; notify_on_complete/watch_patterns disabled", proc_session.id)
return False, None
def _register_completion_watcher(process_registry, proc_session, session_key) -> None:
"""Gateway mode: register a fast watcher so completion triggers a new
agent turn (CLI mode uses the completion_queue directly).
Armed on the live gateway loop right away: the post-turn drain alone leaves a
process that finishes while its launching turn is still running unwatched, and
the chat mute for as long as that turn lasts (#112033). Before the gateway
serves, or while it stops, the descriptor waits in ``pending_watchers`` for the
startup / post-turn drain instead."""
proc_session.watcher_interval = 5
watcher = {
"session_id": proc_session.id, "check_interval": 5, "session_key": session_key,
"platform": proc_session.watcher_platform,
**{attr.removeprefix("watcher_"): getattr(proc_session, attr)
for attr, _ in _ROUTING_FIELDS[:-1]},
"notify_on_complete": True, "parent_session_id": proc_session.parent_session_id,
}
runner_ref = getattr(sys.modules.get("gateway.run"), "_gateway_runner_ref", None)
runner = runner_ref() if callable(runner_ref) else None
if runner is not None and runner.arm_process_watcher(watcher):
return
process_registry.pending_watchers.append(watcher)
def spawn_background_process(
*, command: str, env: Any, env_type: str, effective_task_id: str, task_id: Optional[str],
session_key: str, workdir: Optional[str], cwd: str, effective_pty: bool,
notify_on_complete: bool, watch_patterns: Optional[List[str]], approval_note: Optional[str],
pty_disabled_reason: Optional[str],
) -> str:
"""Spawn *command* as a tracked background process and return the JSON result.
Never inline-polls ``is_interrupted()``: the spawn detaches and returns
exit_code 0 immediately, so the stale-interrupt kill cannot occur here.
"""
from tools.process_registry import process_registry
from tools.terminal_tool import (
_redact_terminal_error_text, _resolve_command_cwd, _resolve_notification_flag_conflict,
)
effective_cwd = _resolve_command_cwd(
workdir=workdir, default_cwd=cwd, session_key=session_key, env_type=env_type,
)
try:
proc_session = _spawn(
process_registry, env=env, env_type=env_type, command=command, cwd=effective_cwd,
effective_task_id=effective_task_id, task_id=task_id, session_key=session_key,
effective_pty=effective_pty,
)
result_data = {"output": "Background process started", "session_id": proc_session.id,
"pid": proc_session.pid, "exit_code": 0, "error": None}
if approval_note:
result_data["approval"] = approval_note
if pty_disabled_reason:
result_data["pty_note"] = pty_disabled_reason
if not notify_on_complete and not watch_patterns:
result_data["hint"] = _SILENT_BACKGROUND_HINT
if command and _looks_like_homebrew_ci_poller(command):
existing = result_data.get("hint", "")
result_data["hint"] = (existing + "\n\n" + _HOMEBREW_CI_POLLER_HINT if existing
else _HOMEBREW_CI_POLLER_HINT)
notify_on_complete, watch_patterns = _apply_async_support(
proc_session, result_data, notify_on_complete, watch_patterns)
watch_patterns, conflict_note = _resolve_notification_flag_conflict(
notify_on_complete=bool(notify_on_complete), watch_patterns=watch_patterns, background=True,
)
if conflict_note:
logger.warning("background proc %s: %s", proc_session.id, conflict_note)
result_data["watch_patterns_ignored"] = conflict_note
if notify_on_complete:
proc_session.notify_on_complete = True
result_data["notify_on_complete"] = True
if proc_session.watcher_platform:
_register_completion_watcher(process_registry, proc_session, session_key)
from agent.delegation_context import is_delegated_child_context
if is_delegated_child_context():
result_data["notify_on_complete"] = False
result_data["subagent_note"] = _SUBAGENT_NOTIFY_NOTE
if watch_patterns:
proc_session.watch_patterns = list(watch_patterns)
result_data["watch_patterns"] = proc_session.watch_patterns
return json.dumps(result_data, ensure_ascii=False)
except Exception as e:
return json.dumps({
"output": "", "exit_code": -1,
"error": _redact_terminal_error_text(f"Failed to start background process: {e}"),
}, ensure_ascii=False)
_SUBAGENT_NOTIFY_NOTE = (
"You are a subagent: this process's completion notice will NOT reach your parent, and the process is killed when "
"you finish. Before you finish, either wait for it (process_manage wait), kill it, or hand it to your parent with "
"process_manage(action='handoff', session_id=..., data='<purpose>') so the parent receives its completion. For CI "
"watchers prefer returning the fact (PR number, SHA) and letting the parent watch."
)
_YIELDED_NOTE = (
"The user sent a message while this command was running, so it was moved to the "
"background WITHOUT being killed and is still running. You will be notified when it "
"exits (notify_on_complete). Read the user's message and respond to it now; use "
"process(action='poll'|'wait'|'log', session_id=...) to check on this command."
)
def yield_to_background_handler(
*, command: str, env_type: str, cwd: Optional[str], effective_task_id: str,
task_id: Optional[str], session_key: str,
):
"""Build the ``yield_handler`` a foreground ``env.execute`` calls when the tool thread is
asked to yield (a user message arrived mid-command). Local backend only: the live Popen
is adopted by the process registry as a notify-on-complete background session and the
partial output is returned to the model right away. Other backends return None (no
adoptable host process) and the foreground wait continues."""
if env_type != "local":
return None
def _handler(proc, output_so_far: str) -> dict:
from tools.process_registry import process_registry
session = process_registry.adopt_local(
proc, command=command, cwd=cwd, task_id=effective_task_id,
owner_task_id=task_id or effective_task_id, session_key=session_key,
output_so_far=output_so_far)
_stamp_routing_if_gateway(process_registry, session, session_key)
logger.info("foreground command yielded to background as %s (pid %s)", session.id, session.pid)
return {
"output": output_so_far, "returncode": None, "yielded_session_id": session.id, "pid": session.pid,
}
return _handler
def _stamp_routing_if_gateway(process_registry, session, session_key) -> None:
"""Route the adopted session's completion like a normal notify_on_complete spawn."""
from gateway.session_context import async_delivery_supported, get_session_env
if not async_delivery_supported():
session.notify_on_complete = False
return
_stamp_gateway_routing(session, get_session_env)
if session.watcher_platform:
_register_completion_watcher(process_registry, session, session_key)