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
2 changes: 1 addition & 1 deletion acp_registry/agent.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "hermes-agent",
"name": "Hermes Agent",
"version": "0.15.1",
"version": "0.15.2",
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
"repository": "https://github.com/NousResearch/hermes-agent",
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
Expand Down
4 changes: 2 additions & 2 deletions hermes_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
import os
import sys

__version__ = "0.15.1"
__release_date__ = "2026.5.29"
__version__ = "0.15.2"
__release_date__ = "2026.5.29.2"


def _ensure_utf8():
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "hermes-agent"
version = "0.15.1"
version = "0.15.2"
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
152 changes: 152 additions & 0 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5459,3 +5459,155 @@ def test_notification_poller_requeues_when_busy(monkeypatch):
assert requeued["session_id"] == "proc_busy_test"
finally:
server._sessions.pop("sid_busy", None)


# ── Tests for slash worker zombie leak fix (#38095) ─────────────────────


def test_finalize_session_closes_slash_worker(monkeypatch):
"""_finalize_session() must close the slash worker to prevent zombies."""
closed = {"called": False}

class _FakeWorker:
def __init__(self, key, model):
self.key = key
self.model = model

def close(self):
closed["called"] = True

session = {
"agent": types.SimpleNamespace(session_id="skey", model="x"),
"session_key": "skey",
"history": [{"role": "user", "content": "hi"}],
"history_lock": threading.Lock(),
"slash_worker": _FakeWorker("skey", "x"),
}

monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **kw: None)
monkeypatch.setattr(
server,
"_get_db",
lambda: types.SimpleNamespace(end_session=lambda *a, **kw: None),
)

server._finalize_session(session)
assert closed["called"] is True, "_finalize_session() did not close slash_worker"


def test_finalize_session_handles_missing_worker(monkeypatch):
"""_finalize_session() must not fail when slash_worker is None."""
session = {
"agent": types.SimpleNamespace(session_id="skey", model="x"),
"session_key": "skey",
"history": [],
"history_lock": threading.Lock(),
"slash_worker": None,
}

monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **kw: None)
monkeypatch.setattr(server, "_get_db", lambda: None)

# Must not raise
server._finalize_session(session)
assert session.get("_finalized") is True


def test_session_close_jsonrpc_closes_worker(monkeypatch):
"""The session.close JSON-RPC handler must close the slash worker."""
closed = {"count": 0}

class _FakeWorker:
def __init__(self, key, model):
self.key = key

def close(self):
closed["count"] += 1

agent = types.SimpleNamespace(session_id="close-sid")
agent.commit_memory_session = lambda history: None
agent.close = lambda: None

server._sessions["close-sid"] = _session(
agent=agent,
session_key="close-key",
slash_worker=_FakeWorker("close-key", "x"),
)

monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **kw: None)
monkeypatch.setattr(
server,
"_get_db",
lambda: types.SimpleNamespace(end_session=lambda *a, **kw: None),
)

import tools.approval as _approval

monkeypatch.setattr(_approval, "unregister_gateway_notify", lambda key: None)

try:
resp = server.handle_request(
{"id": "1", "method": "session.close", "params": {"session_id": "close-sid"}}
)
assert resp["result"]["closed"] is True
# Worker should have been closed at least once (via _finalize_session).
assert closed["count"] >= 1, f"worker.close() called {closed['count']} times, expected >= 1"
finally:
server._sessions.pop("close-sid", None)


def test_restart_slash_worker_clears_old_reference(monkeypatch):
"""_restart_slash_worker must close old worker and clear reference before creating new."""
closed = {"old": False}
created = {"count": 0}

class _OldWorker:
def close(self):
closed["old"] = True

class _NewWorker:
def __init__(self, key, model):
self.key = key
self.model = model
created["count"] += 1

session = {
"agent": types.SimpleNamespace(session_id="rk", model="x"),
"session_key": "rk",
"slash_worker": _OldWorker(),
}

monkeypatch.setattr(server, "_SlashWorker", _NewWorker)

server._restart_slash_worker(session)

assert closed["old"] is True, "old worker was not closed"
assert created["count"] == 1, f"new worker created {created['count']} times"
assert session["slash_worker"].key == "rk"
assert session["slash_worker"].model == "x"


def test_slash_worker_close_reaps_process(monkeypatch):
"""_SlashWorker.close() must terminate, drain pipes, and reap the process."""
import subprocess

proc = subprocess.Popen(
[sys.executable, "-c", "import time; time.sleep(300)"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
# Confirm it's alive
assert proc.poll() is None

# Bypass __init__ and attach proc directly
import types as _types
w = _types.SimpleNamespace()
w.proc = proc
w.close = server._SlashWorker.close.__get__(w, server._SlashWorker)

w.close()

# Process must be reaped (poll() returns exit code, not None)
assert w.proc.poll() is not None, "worker process was not reaped after close()"
29 changes: 28 additions & 1 deletion tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,12 +258,29 @@ def close(self):
try:
if self.proc.poll() is None:
self.proc.terminate()
self.proc.wait(timeout=1)
self.proc.wait(timeout=5)
except Exception:
try:
self.proc.kill()
except Exception:
pass
try:
self.proc.stdout.close()
except Exception:
pass
try:
self.proc.stderr.close()
except Exception:
pass
try:
self.proc.stdin.close()
except Exception:
pass
# Final reap to avoid zombies.
try:
self.proc.wait(timeout=2)
except Exception:
pass


def _load_busy_input_mode() -> str:
Expand Down Expand Up @@ -322,6 +339,15 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No
except Exception:
pass

# Close the slash worker subprocess to prevent zombie accumulation.
# Fix for #38095.
worker = session.get("slash_worker")
if worker:
try:
worker.close()
except Exception:
pass


def _shutdown_sessions() -> None:
for session in list(_sessions.values()):
Expand Down Expand Up @@ -1215,6 +1241,7 @@ def _restart_slash_worker(session: dict):
worker.close()
except Exception:
pass
session["slash_worker"] = None
try:
session["slash_worker"] = _SlashWorker(
session["session_key"],
Expand Down