245 lines
10 KiB
Python
245 lines
10 KiB
Python
"""Background-process launch path of the terminal tool.
|
|
|
|
``terminal(background=true)`` spawns a tracked process through the process
|
|
registry (Popen for the local backend, ``env.execute`` inside the sandbox
|
|
otherwise), stamps gateway routing metadata for completion / watch-pattern
|
|
notifications, and returns the JSON result. Split out of
|
|
tools/terminal_tool.py; lazy ``from tools.terminal_tool import ...`` keeps
|
|
the origin module's monkeypatch points authoritative.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
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')."
|
|
)
|
|
|
|
|
|
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
|
|
proc_session.watcher_chat_id = get_session_env("HERMES_SESSION_CHAT_ID", "")
|
|
proc_session.watcher_user_id = get_session_env("HERMES_SESSION_USER_ID", "")
|
|
proc_session.watcher_user_name = get_session_env("HERMES_SESSION_USER_NAME", "")
|
|
proc_session.watcher_thread_id = get_session_env("HERMES_SESSION_THREAD_ID", "")
|
|
proc_session.watcher_message_id = get_session_env("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.
|
|
proc_session.parent_session_id = get_session_env("HERMES_SESSION_ID", "")
|
|
|
|
|
|
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:
|
|
if env_type == "local":
|
|
proc_session = process_registry.spawn_local(
|
|
command=command,
|
|
cwd=effective_cwd,
|
|
task_id=effective_task_id,
|
|
owner_task_id=task_id or effective_task_id,
|
|
session_key=session_key,
|
|
env_vars=env.env if hasattr(env, 'env') else None,
|
|
use_pty=effective_pty,
|
|
)
|
|
else:
|
|
proc_session = process_registry.spawn_via_env(
|
|
env=env,
|
|
command=command,
|
|
cwd=effective_cwd,
|
|
task_id=effective_task_id,
|
|
owner_task_id=task_id or effective_task_id,
|
|
session_key=session_key,
|
|
)
|
|
|
|
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
|
|
)
|
|
|
|
if notify_on_complete or watch_patterns:
|
|
from gateway.session_context import (
|
|
async_delivery_supported as _async_ok,
|
|
get_session_env as _gse,
|
|
)
|
|
|
|
# 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.
|
|
if not _async_ok():
|
|
notify_on_complete = False
|
|
watch_patterns = None
|
|
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,
|
|
)
|
|
else:
|
|
_stamp_gateway_routing(proc_session, _gse)
|
|
|
|
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
|
|
# Gateway mode: register a fast watcher so completion triggers a
|
|
# new agent turn (CLI mode uses the completion_queue directly).
|
|
if proc_session.watcher_platform:
|
|
proc_session.watcher_interval = 5
|
|
process_registry.pending_watchers.append({
|
|
"session_id": proc_session.id,
|
|
"check_interval": 5,
|
|
"session_key": session_key,
|
|
"platform": proc_session.watcher_platform,
|
|
"chat_id": proc_session.watcher_chat_id,
|
|
"user_id": proc_session.watcher_user_id,
|
|
"user_name": proc_session.watcher_user_name,
|
|
"thread_id": proc_session.watcher_thread_id,
|
|
"message_id": proc_session.watcher_message_id,
|
|
"notify_on_complete": True,
|
|
"parent_session_id": proc_session.parent_session_id,
|
|
})
|
|
|
|
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)
|