From f24a5c86970de29cc78068f1d9ba79bbdac098a0 Mon Sep 17 00:00:00 2001 From: islam666 Date: Wed, 3 Jun 2026 09:58:25 +0000 Subject: [PATCH 1/2] fix(version): bump main branch to 0.15.2 to match latest release tag The main branch reported 0.15.1 / 2026.5.29 while the latest published release tag v2026.5.29.2 reports 0.15.2 / 2026.5.29.2. This caused source installs tracking origin/main to appear stale/downgraded. Update pyproject.toml, hermes_cli/__init__.py, and acp_registry/agent.json to match the release tag metadata. Fixes #38093 --- acp_registry/agent.json | 2 +- hermes_cli/__init__.py | 4 ++-- pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/acp_registry/agent.json b/acp_registry/agent.json index 966444ec912e3..bb0e43a9c6174 100644 --- a/acp_registry/agent.json +++ b/acp_registry/agent.json @@ -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", diff --git a/hermes_cli/__init__.py b/hermes_cli/__init__.py index 5e6a99ac0efd0..d29117ec95248 100644 --- a/hermes_cli/__init__.py +++ b/hermes_cli/__init__.py @@ -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(): diff --git a/pyproject.toml b/pyproject.toml index bb5faf3a95cf5..7d1048c033df3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" From f7355a1a44dbee1fb01354c6f9e9b45600b12e68 Mon Sep 17 00:00:00 2001 From: islam666 Date: Wed, 3 Jun 2026 10:20:34 +0000 Subject: [PATCH 2/2] Fix TUI slash worker zombie subprocess leak (#38095) Three related fixes in tui_gateway/server.py: 1. _finalize_session() now closes the slash worker subprocess before returning. Previously, any code path that called _finalize_session() without separately handling the worker would leak the subprocess. 2. _SlashWorker.close() is more robust: increased terminate->wait timeout from 1s to 5s, added explicit stdout/stderr/stdin close to drain reader threads, and a final wait(2) to reap the zombie. The old 1s timeout was too short for slow machines under load. 3. _restart_slash_worker() now clears session[\"slash_worker\"] = None after closing the old worker, preventing a window where both old and new workers are simultaneously live (observed as duplicate session-key workers in the wild). Co-Authored-By: OWL --- tests/test_tui_gateway_server.py | 152 +++++++++++++++++++++++++++++++ tui_gateway/server.py | 29 +++++- 2 files changed, 180 insertions(+), 1 deletion(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index df6adbc41f224..f9611ac3227bf 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -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()" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index a70dd3efedf04..aa2498a7aff29 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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: @@ -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()): @@ -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"],