Honcho one-shot mode SIGABRT shutdown fixes
NousResearch/hermes-agent · cluster 30387 · agent
Data as of
3 open items. Fix merged in #67905. Check it covers your case.
The short answer
- Known?
- Yes. 15 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #67905. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #67905. That is separate from the fix event. - Still open
- 3 issues and 0 PRs.
Next step: read #67905, then check whether your report is one of the 3 still open.
Read #67905Pick up a Lab taskThese issues and pull requests center on a SIGABRT (exit 134) crash occurring during teardown of the 'hermes -z' one-shot mode, caused by Honcho memory daemon threads blocked in HTTP I/O during interpreter finalization. Work spans bug reports across platforms, fixes for thread joining and shutdown sequencing, and related Honcho integration features.
Open issues3
- Issue#37632hermes -z one-shot crashes with SIGABRT (exit 134) on exit: Honcho memory daemon threads blocked in httpx I/O during interpreter finalization
- Issue#43055Bug: hermes -z aborts with SIGABRT during teardown on AL2023
- Issue#60502hermes -z one-shot: correct output emitted, then SIGABRT (exit 134) at teardown when fallback provider is configured (v0.18.0)
Open PRs0
Fixes on record
-
#67905 fix(cli): prevent SIGABRT (exit 134) during hermes -z oneshot teardown
files:
hermes_cli/main.py,hermes_cli/oneshot.py,tests/hermes_cli/test_tui_resume_flow.pyShow the diff
-
hermes_cli/main.py@@ -65,6 +65,125 @@ import sys +def _exit_after_oneshot(rc: object) -> None: + """Exit one-shot mode without letting late native finalizers change rc. + + The SIGABRT this guards against (#30387, #43055) fires in a + native-extension finalizer during CPython's ``Py_FinalizeEx``, *after* + the response has printed. Flush streams, shut down file logging, then + ``os._exit`` past interpreter finalization. The ``atexit`` chain is + deliberately skipped — several handlers re-enter native code that may + be the abort source. Stateful cleanup is handled in ``_run_agent`` and + ``_cleanup_oneshot_runtime``. + """ + for stream in (sys.stdout, sys.stderr): + try: + stream.flush() + except Exception: + pass + try: + logging.shutdown() + except Exception: + pass + if rc is None: + exit_code = 0 + elif isinstance(rc, int): + exit_code = rc + else: + exit_code = 1 + os._exit(exit_code) + + +_oneshot_cleanup_done = False + + +def _cleanup_oneshot_runtime() -> None: + """Best-effort process-global cleanup before one-shot hard exit. + + ``run_oneshot`` owns the agent-local cleanup (memory provider, agent.close, + session_db.close — all in ``_run_agent``'s finally block). This mirrors the + process-global pieces from ``cli.py:_run_cleanup()`` that would otherwise + be skipped by ``os._exit``. + """ + global _oneshot_cleanup_done + if _oneshot_cleanup_done: + return + _oneshot_cleanup_done = True + try: + from tools.terminal_tool import cleanup_all_environments + cleanup_all_environments() + except Exception: + pass + try: + from tools.async_delegation import interrupt_all + interrupt_all(reason="oneshot shutdown") + except Exception: + pass + try: + from tools.browser_tool import _emergency_cleanup_all_sessions + _emergency_cleanup_all_sessions() + except Exception: + pass + try: + from tools.mcp_tool import shutdown_mcp_servers + shutdown_mcp_servers() + except BaseException: + pass + try: + from agent.auxiliary_client import shutdown_cached_clients + shutdown_cached_clients() + except Exception: + pass + + +def _run_and_exit_oneshot( + prompt: str, + *, + model: object = None, + provider: object = None, + toolsets: object = None, + usage_file: object = None, +) -> None: + try: + from hermes_cli.oneshot import run_oneshot + + rc = run_oneshot( + prompt, + model=model, + provider=provider, + toolsets=toolsets, + usage_file=usage_file, + ) + except KeyboardInterrupt: + rc = 130 + except SystemExit as exc: + if exc.code is not None and not isinstance(exc.code, int): + print(exc.code, file=sys.stderr) + rc = 1 + else: + rc = exc.code + except BaseException: + # Defense-in-depth. ``run_oneshot`` already converts agent failures + # into an int return code and only re-raises KeyboardInterrupt / + # SystemExit (handled above). Anything still escaping here means + # ``run_oneshot`` itself malfunctioned — surface it on stderr but never + # fall through to normal interpreter teardown, which is the exact path + # that aborts with SIGABRT on AL2023 (the bug this routine fixes). + import traceback + try: + traceback.print_exc() + except Exception: + pass + rc = 1 + try: + _cleanup_oneshot_runtime() + finally: + # The hard exit is the safety boundary for #43055. Even an interrupt + # during best-effort cleanup must not fall back into interpreter + # finalization, where the reported native SIGABRT occurs. + _exit_after_oneshot(rc) + + def _set_process_title() -> None: """Set the process title to 'hermes' so tools like 'ps', 'top', and 'htop' show the app name instead of 'python3.xx'. @@ -12973,16 +13092,12 @@ def _try_termux_fast_cli_launch() -> bool: if getattr(args, "oneshot", None): _prepare_agent_startup(args) - from hermes_cli.oneshot import run_oneshot - - sys.exit( - run_oneshot( - args.oneshot, - model=getattr(args, "model", None), - provider=getattr(args, "provider", None), - toolsets=getattr(args, "toolsets", None), - usage_file=getattr(args, "usage_file", None), - ) + _run_and_exit_oneshot( + args.oneshot, + model=getattr(args, "model", None), + provider=getattr(args, "provider", None), + toolsets=getattr(args, "toolsets", None), + usage_file=getattr(args, "usage_file", None), ) if (args.resume or args.continue_last) and args.command is None: @@ -15130,16 +15245,12 @@ def _export_one(session_id: str): # Handle top-level --oneshot / -z: single-shot mode, stdout = final # response only, nothing else. Bypasses cli.py entirely. if getattr(args, "oneshot", None): - from hermes_cli.oneshot import run_oneshot - - sys.exit( - run_oneshot( - args.oneshot, - model=getattr(args, "model", None), - provider=getattr(args, "provider", None), - toolsets=getattr(args, "toolsets", None), - usage_file=getattr(args, "usage_file", None), - ) + _run_and_exit_oneshot( + args.oneshot, + model=getattr(args, "model", None), + provider=getattr(args, "provider", None), + toolsets=getattr(args, "toolsets", None), + usage_file=getattr(args, "usage_file", None), ) # Handle top-level --resume / --continue as shortcut to chat -
hermes_cli/oneshot.py@@ -188,7 +188,7 @@ def run_oneshot( run — even when the run fails — so pipelines can account for spend per invocation. - Returns the exit code. Caller should sys.exit() with the return. + Returns the exit code. The caller owns process termination. """ # Silence every stdlib logger for the duration. AIAgent, tools, and # provider adapters all log to stderr through the root logger; file @@ -396,44 +396,76 @@ def _run_agent( toolsets_list = sorted(_get_platform_tools(cfg, "cli")) session_db = _create_session_db_for_oneshot() - # Read the effective fallback chain from profile config so oneshot workers - # honour the same merge semantics as interactive CLI and gateway sessions. - _fb = get_fallback_chain(cfg) - - agent = AIAgent( - api_key=[redacted:secret-assignment]"api_key"), - base_url=runtime.get("base_url"), - provider=runtime.get("provider"), - api_mode=runtime.get("api_mode"), - model=effective_model, - enabled_toolsets=toolsets_list, - quiet_mode=True, - platform="cli", - session_db=session_db, - credential_pool=[redacted:secret-assignment]"credential_pool"), - fallback_model=_fb or None, - # Interactive callbacks are intentionally NOT wired beyond this - # one. In oneshot mode there's no user sitting at a terminal: - # - clarify → returns a synthetic "pick a default" instruction - # so the agent continues instead of stalling on - # the tool's built-in "not available" error - # - sudo password prompt → terminal_tool gates on - # HERMES_INTERACTIVE which we never set - # - shell-hook approval → auto-approved via HERMES_ACCEPT_HOOKS=1 - # (set above); also falls back to deny on non-tty - # - dangerous-command approval → bypassed via HERMES_YOLO_MODE=1 - # - skill secret capture → returns gracefully when no callback set - clarify_callback=_oneshot_clarify_callback, - ) + # The try spans agent construction (not just ``chat``) so the SQLite store + # opened above is always closed — including when ``AIAgent(...)`` itself + # raises on a provider/config error. The one-shot exit path hard-exits via + # os._exit and skips finalizers, so an un-closed connection here would leak. + agent = None + try: + # Read the effective fallback chain from profile config so oneshot + # workers honour the same merge semantics as interactive CLI and + # gateway sessions. + _fb = get_fallback_chain(cfg) + + agent = AIAgent( + api_key=[redacted:secret-assignment]"api_key"), + base_url=runtime.get("base_url"), + provider=runtime.get("provider"), + api_mode=runtime.get("api_mode"), + model=effective_model, + enabled_toolsets=toolsets_list, + quiet_mode=True, + platform="cli", + session_db=session_db, + credential_pool=[redacted:secret-assignment]"credential_pool"), + fallback_model=_fb or None, + # Interactive callbacks are intentionally NOT wired beyond this + # one. In oneshot mode there's no user sitting at a terminal: + # - clarify → returns a synthetic "pick a default" instruction + # so the agent continues instead of stalling on + # the tool's built-in "not available" error + # - sudo password prompt → terminal_tool gates on + # HERMES_INTERACTIVE which we never set + # - shell-hook approval → auto-approved via HERMES_ACCEPT_HOOKS=1 + # (set above); also falls back to deny on non-tty + # - dangerous-command approval → bypassed via HERMES_YOLO_MODE=1 + # - skill secret capture → returns gracefully when no callback set + clarify_callback=_oneshot_clarify_callback, + ) - # Belt-and-braces: make sure AIAgent doesn't invoke any streaming - # display callbacks that would bypass our stdout capture. - agent.suppress_status_output = True - agent.stream_delta_callback = None - agent.tool_gen_callback = None + # Belt-and-braces: make sure AIAgent doesn't invoke any streaming + # display callbacks that would bypass our stdout capture. + agent.suppress_status_output = True + agent.stream_delta_callback = None + agent.tool_gen_callback = None - result = agent.run_conversation(prompt) - return (result.get("final_response") or "", result) + result = agent.run_conversation(prompt) + return (result.get("final_response") or "", result) + finally: + # Ordering deliberately mirrors gateway/run.py:_cleanup_agent_resources, + # NOT cli.py:_run_cleanup — oneshot has no _active_agent_ref and must + # close the agent explicitly because the hard-exit path skips finalizers. + if agent is not None: + try: + session_messages = getattr(agent, "_session_messages", None) + if isinstance(session_messages, list): + agent.shutdown_memory_provider(session_messages) + else: + agent.shutdown_memory_provider() + except Exception: + logging.debug("oneshot memory/context cleanup failed", exc_info=True) + try: + agent.close() + except Exception: + logging.debug("oneshot agent cleanup failed", exc_info=True) + # agent.close() calls session_db.end_session() but leaves the connection + # open; close it here to checkpoint the WAL before os._exit skips + # finalizers. + if session_db is not None: + try: + session_db.close() + except Exception: + logging.debug("oneshot session store cleanup failed", exc_info=True) def _oneshot_clarify_callback(question: str, choices=None) -> str: -
tests/hermes_cli/test_tui_resume_flow.py@@ -1,7 +1,9 @@ from argparse import Namespace import os from pathlib import Path +import subprocess import sys +import textwrap import types import pytest @@ -21,11 +23,17 @@ def _args(**overrides): return Namespace(**base) +def _raise_exit(rc): + raise SystemExit(rc) + + @pytest.fixture def main_mod(monkeypatch): import hermes_cli.main as mod monkeypatch.setattr(mod, "_has_any_provider_configured", lambda: True) + # Reset the idempotency guard so each test starts fresh. + monkeypatch.setattr(mod, "_oneshot_cleanup_done", False) return mod @@ -353,7 +361,17 @@ def test_termux_fast_cli_launch_oneshot_uses_light_parser(monkeypatch, main_mod) monkeypatch.setattr( sys, "argv", - ["hermes", "-z", "hello", "--model", "gpt-test", "--provider", "openai"], + [ + "hermes", + "-z", + "hello", + "--model", + "gpt-test", + "--provider", + "openai", + "--usage-file", + "usage.json", + ], ) monkeypatch.setattr( main_mod, "_prepare_agent_startup", lambda args: prepared.append(args.command) @@ -368,6 +386,11 @@ def test_termux_fast_cli_launch_oneshot_uses_light_parser(monkeypatch, main_mod) or 17 ), ) + monkeypatch.setattr( + main_mod, + "_exit_after_oneshot", + _raise_exit, + ) with pytest.raises(SystemExit) as exc: main_mod._try_termux_fast_cli_launch() @@ -379,7 +402,7 @@ def test_termux_fast_cli_launch_oneshot_uses_light_parser(monkeypatch, main_mod) "model": "gpt-test", "provider": "openai", "toolsets": None, - "usage_file": None, + "usage_file": "usage.json", } @@ -577,7 +600,17 @@ def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod): import hermes_cli.config as config_mod monkeypatch.setattr( - sys, "argv", ["hermes", "-z", "hello", "--toolsets", "web,terminal"] + sys, + "argv", + [ + "hermes", + "-z", + "hello", + "--toolsets", + "web,terminal", + "--usage-file", + "usage.json", + ], ) monkeypatch.setitem( sys.modules, @@ -608,6 +641,11 @@ def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod): or 0 ), ) + monkeypatch.setattr( + main_mod, + "_exit_after_oneshot", + _raise_exit, + ) with pytest.raises(SystemExit) as exc: main_mod.main() @@ -618,10 +656,721 @@ def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod): "model": None, "provider": None, "toolsets": "web,terminal", - "usage_file": None, + "usage_file": "usage.json", } +def test_exit_after_oneshot_flushes_stdio_and_calls_os_exit( + monkeypatch, main_mod +): + flushed = [] + exits = [] + + class FakeStream: + def __init__(self, name): + self.name = name + + def flush(self): + flushed.append(self.name) + + def fake_exit(rc): + exits.append(rc) + raise SystemExit(rc) + + monkeypatch.setattr(main_mod.sys, "stdout", FakeStream("stdout")) + monkeypatch.setattr(main_mod.sys, "stderr", FakeStream("stderr")) + monkeypatch.setattr(main_mod.os, "_exit", fake_exit) + monkeypatch.setattr("logging.shutdown", lambda: None) + + with pytest.raises(SystemExit) as exc: + main_mod._exit_after_oneshot(17) + + assert exc.value.code == 17 + assert exits == [17] + assert flushed == ["stdout", "stderr"] + + +def test_exit_after_oneshot_invokes_logging_shutdown_in_order( + monkeypatch, main_mod +): + events = [] + + class FakeStream: + def __init__(self, name): + self.name = name + + def flush(self): + events.append(f"flush:{self.name}") + + def fake_exit(rc): + events.append(f"exit:{rc}") + raise SystemExit(rc) + + monkeypatch.setattr(main_mod.sys, "stdout", FakeStream("stdout")) + monkeypatch.setattr(main_mod.sys, "stderr", FakeStream("stderr")) + monkeypatch.setattr("logging.shutdown", lambda: events.append("shutdown")) + monkeypatch.setattr(main_mod.os, "_exit", fake_exit) + + with pytest.raises(SystemExit) as exc: + main_mod._exit_after_oneshot(0) + + assert exc.value.code == 0 + assert events == ["flush:stdout", "flush:stderr", "shutdown", "exit:0"] + + +def test_exit_after_oneshot_exits_even_if_logging_shutdown_raises( + monkeypatch, main_mod +): + exits = [] + + def fake_exit(rc): + exits.append(rc) + raise SystemExit(rc) + + monkeypatch.setattr( + main_mod.sys, "stdout", types.SimpleNamespace(flush=lambda: None) + ) + monkeypatch.setattr( + main_mod.sys, "stderr", types.SimpleNamespace(flush=lambda: None) + ) + monkeypatch.setattr( + "logging.shutdown", + lambda: (_ for _ in ()).throw(RuntimeError("shutdown failed")), + ) + monkeypatch.setattr(main_mod.os, "_exit", fake_exit) + + with pytest.raises(SystemExit) as exc: + main_mod._exit_after_oneshot(1) + + assert exc.value.code == 1 + assert exits == [1] + + +def test_exit_after_oneshot_flushes_stderr_when_stdout_flush_fails( + monkeypatch, main_mod +): + flushed = [] + exits = [] + + class BadStdout: + def flush(self): + raise BrokenPipeError("pipe closed") + + class FakeStderr: + def flush(self): + flushed.append("stderr") + + def fake_exit(rc): + exits.append(rc) + raise SystemExit(rc) + + monkeypatch.setattr(main_mod.sys, "stdout", BadStdout()) + monkeypatch.setattr(main_mod.sys, "stderr", FakeStderr()) + monkeypatch.setattr(main_mod.os, "_exit", fake_exit) + monkeypatch.setattr("logging.shutdown", lambda: None) + + with pytest.raises(SystemExit) as exc: + main_mod._exit_after_oneshot(2) + + assert exc.value.code == 2 + assert exits == [2] + assert flushed == ["stderr"] + + +def test_exit_after_oneshot_normalizes_non_int_exit_code(monkeypatch, main_mod): + exits = [] + + def fake_exit(rc): + exits.append(rc) + raise SystemExit(rc) + + monkeypatch.setattr(main_mod.os, "_exit", fake_exit) + monkeypatch.setattr("logging.shutdown", lambda: None) + + with pytest.raises(SystemExit) as exc: + main_mod._exit_after_oneshot(None) + + assert exc.value.code == 0 + assert exits == [0] + + +def test_run_and_exit_oneshot_routes_system_exit_to_hard_exit(monkeypatch, main_mod): + exits = [] + + def fake_run_oneshot(*_args, **_kwargs): + raise SystemExit(2) + + monkeypatch.setitem( + sys.modules, + "hermes_cli.oneshot", + types.SimpleNamespace(run_oneshot=fake_run_oneshot), + ) + monkeypatch.setattr(main_mod, "_cleanup_oneshot_runtime", lambda: None) + monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc)) + + main_mod._run_and_exit_oneshot("hello") + + assert exits == [2] + + +def test_run_and_exit_oneshot_routes_bare_system_exit_to_zero(monkeypatch, main_mod): + exits = [] + + def fake_run_oneshot(*_args, **_kwargs): + raise SystemExit + + monkeypatch.setitem( + sys.modules, + "hermes_cli.oneshot", + types.SimpleNamespace(run_oneshot=fake_run_oneshot), + ) + monkeypatch.setattr(main_mod, "_cleanup_oneshot_runtime", lambda: None) + monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc)) + + main_mod._run_and_exit_oneshot("hello") + + assert exits == [None] + + +def test_run_and_exit_oneshot_prints_system_exit_message( + monkeypatch, capsys, main_mod +): + exits = [] + + def fake_run_oneshot(*_args, **_kwargs): + raise SystemExit("fatal") + + monkeypatch.setitem( + sys.modules, + "hermes_cli.oneshot", + types.SimpleNamespace(run_oneshot=fake_run_oneshot), + ) + monkeypatch.setattr(main_mod, "_cleanup_oneshot_runtime", lambda: None) + monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc)) + + main_mod._run_and_exit_oneshot("hello") + + assert exits == [1] + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "fatal\n" + + +def test_run_and_exit_oneshot_cleans_global_runtime_before_hard_exit( + monkeypatch, main_mod +): + events = [] + + def _mod(name, **attrs): + fake = types.ModuleType(name) + for key, value in attrs.items(): + setattr(fake, key, value) + return fake + + monkeypatch.setitem( + sys.modules, + "hermes_cli.oneshot", + types.SimpleNamespace(run_oneshot=lambda *_args, **_kwargs: events.append("run") or 0), + ) + monkeypatch.setitem( + sys.modules, + "tools.terminal_tool", + _mod("tools.terminal_tool", cleanup_all_environments=lambda: events.append("terminal")), + ) + monkeypatch.setitem( + sys.modules, + "tools.async_delegation", + _mod("tools.async_delegation", interrupt_all=lambda **kw: events.append("delegation")), + ) + monkeypatch.setitem( + sys.modules, + "tools.browser_tool", + _mod("tools.browser_tool", _emergency_cleanup_all_sessions=lambda: events.append("browser")), + ) + monkeypatch.setitem( + sys.modules, + "tools.mcp_tool", + _mod("tools.mcp_tool", shutdown_mcp_servers=lambda: events.append("mcp")), + ) + monkeypatch.setitem( + sys.modules, + "agent.auxiliary_client", + _mod("agent.auxiliary_client", shutdown_cached_clients=lambda: events.append("aux")), + ) + monkeypatch.setattr( + main_mod, "_exit_after_oneshot", lambda rc: events.append(f"exit:{rc}") + ) + + main_mod._run_and_exit_oneshot("hello") + + assert events == ["run", "terminal", "delegation", "browser", "mcp", "aux", "exit:0"] + + +def test_run_and_exit_oneshot_still_exits_when_global_cleanup_raises( + monkeypatch, main_mod +): + events = [] + + def _raise_mcp(): + raise RuntimeError("mcp boom") + + monkeypatch.setitem( + sys.modules, + "hermes_cli.oneshot", + types.SimpleNamespace(run_oneshot=lambda *_args, **_kwargs: 0), + ) + monkeypatch.setitem( + sys.modules, + "tools.terminal_tool", + types.SimpleNamespace(cleanup_all_environments=lambda: events.append("terminal")), + ) + monkeypatch.setitem( + sys.modules, + "tools.async_delegation", + types.SimpleNamespace(interrupt_all=lambda **kw: events.append("delegation")), + ) + monkeypatch.setitem( + sys.modules, + "tools.browser_tool", + types.SimpleNamespace(_emergency_cleanup_all_sessions=lambda: events.append("browser")), + ) + monkeypatch.setitem( + sys.modules, + "tools.mcp_tool", + types.SimpleNamespace(shutdown_mcp_servers=_raise_mcp), + ) + monkeypatch.setitem( + sys.modules, + "agent.auxiliary_client", + types.SimpleNamespace(shutdown_cached_clients=lambda: events.append("aux")), + ) + monkeypatch.setattr( + main_mod, "_exit_after_oneshot", lambda rc: events.append(f"exit:{rc}") + ) + + main_mod._run_and_exit_oneshot("hello") + + assert events == ["terminal", "delegation", "browser", "aux", "exit:0"] + + +def test_run_and_exit_oneshot_hard_exits_when_cleanup_is_interrupted( + monkeypatch, main_mod +): + def _raise_keyboard_interrupt(): + raise KeyboardInterrupt + + monkeypatch.setitem( + sys.modules, + "hermes_cli.oneshot", + types.SimpleNamespace(run_oneshot=lambda *_args, **_kwargs: 0), + ) + monkeypatch.setattr( + main_mod, "_cleanup_oneshot_runtime", _raise_keyboard_interrupt + ) + monkeypatch.setattr(main_mod, "_exit_after_oneshot", _raise_exit) + + with pytest.raises(SystemExit) as exc: + main_mod._run_and_exit_oneshot("hello") + + assert exc.value.code == 0 + + +def test_run_and_exit_oneshot_routes_keyboard_interrupt_to_130( + monkeypatch, main_mod +): + exits = [] + + def fake_run_oneshot(*_args, **_kwargs): + raise KeyboardInterrupt + + monkeypatch.setitem( + sys.modules, + "hermes_cli.oneshot", + types.SimpleNamespace(run_oneshot=fake_run_oneshot), + ) + monkeypatch.setattr(main_mod, "_cleanup_oneshot_runtime", lambda: None) + monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc)) + + main_mod._run_and_exit_oneshot("hello") + + assert exits == [130] + + +def test_run_and_exit_oneshot_hard_exits_on_unexpected_exception( + monkeypatch, main_mod, capsys +): + # ``run_oneshot`` is contracted to convert agent failures into an int and + # only re-raise KeyboardInterrupt / SystemExit. If it ever malfunctions and + # lets another exception escape, the one-shot path must still hard-exit + # (rc 1) rather than fall through to interpreter teardown — the exact path + # that SIGABRTs on AL2023. + exits = [] + cleaned = [] + + def fake_run_oneshot(*_args, **_kwargs): + raise RuntimeError("run_oneshot itself blew up") + + monkeypatch.setitem( + sys.modules, + "hermes_cli.oneshot", + types.SimpleNamespace(run_oneshot=fake_run_oneshot), + ) + monkeypatch.setattr( + main_mod, "_cleanup_oneshot_runtime", lambda: cleaned.append(True) + ) + monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc)) + + main_mod._run_and_exit_oneshot("hello") + + assert exits == [1] + # Global cleanup still runs on the defensive path — resources must not leak + # just because run_oneshot malfunctioned. + assert cleaned == [True] + # The failure is surfaced on stderr, never swallowed silently. + assert "run_oneshot itself blew up" in capsys.readouterr().err + + +def test_run_and_exit_oneshot_hard_exits_when_oneshot_import_fails( + monkeypatch, main_mod, capsys +): + import builtins + + exits = [] + cleaned = [] + real_import = builtins.__import__ + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "hermes_cli.oneshot" and "run_oneshot" in (fromlist or ()): + raise RuntimeError("oneshot import blew up") + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", fake_import) + monkeypatch.setattr( + main_mod, "_cleanup_oneshot_runtime", lambda: cleaned.append(True) + ) + monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc)) + + main_mod._run_and_exit_oneshot("hello") + + assert exits == [1] + assert cleaned == [True] + assert "oneshot import blew up" in capsys.readouterr().err + + +def test_oneshot_subprocess_exits_without_teardown_abort(): + program = textwrap.dedent( + """ + import hermes_cli.oneshot as oneshot + from hermes_cli.main import _exit_after_oneshot + + oneshot._run_agent = lambda *args, **kwargs: ("ok", {"final_response": "ok"}) + _exit_after_oneshot(oneshot.run_oneshot("hello")) + """ + ) + + result = subprocess.run( + [sys.executable, "-c", program], + cwd=Path(__file__).resolve().parents[2], + capture_output=True, + timeout=10, + check=False, + ) + + assert result.returncode == 0 + assert result.stdout == b"ok\n" + # Don't demand byte-empty stderr — an import-time warning from the heavy + # CLI import chain shouldn't fail this. What matters is no crash traceback. + assert b"Traceback" not in result.stderr + + +def test_exit_after_oneshot_bypasses_late_atexit_abort(): + program = textwrap.dedent( + """ + import atexit + import os + import sys + from hermes_cli.main import _exit_after_oneshot + + atexit.register(os.abort) + sys.stdout.write("done\\n") + _exit_after_oneshot(0) + """ + ) + + result = subprocess.run( + [sys.executable, "-c", program], + cwd=Path(__file__).resolve().parents[2], + capture_output=True, + timeout=10, + check=False, + ) + + assert result.returncode == 0 + assert result.stdout == b"done\n" + + +def test_run_and_exit_oneshot_passes_through_nonzero_return(monkeypatch, main_mod): + # A non-zero rc from run_oneshot (e.g. provider-without-model → 2, or the + # empty-response guard → 1) must reach os._exit unchanged. + exits = [] + monkeypatch.setitem( + sys.modules, + "hermes_cli.oneshot", + types.SimpleNamespace(run_oneshot=lambda *a, **k: 2), + ) + monkeypatch.setattr(main_mod, "_cleanup_oneshot_runtime", lambda: None) + monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc)) + + main_mod._run_and_exit_oneshot("hi") + + assert exits == [2] + + +def test_main_oneshot_path_bypasses_late_atexit_abort(): + # End-to-end through the real top-level ``main()`` ``-z`` path: a valid + # response prints, then a late atexit handler that would abort is bypassed + # by the hard exit, so the process reports success (#43055). + program = textwrap.dedent( + """ + import atexit + import os + import sys + import types + + import hermes_cli.main as main_mod + + sys.argv = ["hermes", "-z", "hello"] + main_mod._prepare_agent_startup = lambda args: None + + def _fake_run_oneshot(prompt, **kwargs): + print("ok") + return 0 + + sys.modules["hermes_cli.oneshot"] = types.SimpleNamespace( + run_oneshot=_fake_run_oneshot + ) + atexit.register(os.abort) + main_mod.main() + """ + ) + + result = subprocess.run( + [sys.executable, "-c", program], + cwd=Path(__file__).resolve().parents[2], + capture_output=True, + timeout=30, + check=False, + ) + + assert result.returncode == 0 + assert result.stdout == b"ok\n" + assert b"Traceback" not in result.stderr + + +def test_oneshot_run_agent_closes_agent_after_chat(monkeypatch): + import hermes_cli.oneshot as oneshot_mod + + closed = [] + shutdown_messages = [] + + class FakeAgent: + def __init__(self, **_kwargs): + self.suppress_status_output = False + self.stream_delta_callback = object() + self.tool_gen_callback = object() + self._session_messages = [{"role": "user", "content": "hello"}] + + def run_conversation(self, prompt, **_kwargs): + assert prompt == "hello" + return {"final_response": "done"} + + def shutdown_memory_provider(self, messages=None): + shutdown_messages.append(messages) + + def close(self): + closed.append(True) + + monkeypatch.setitem( + sys.modules, "run_agent", types.SimpleNamespace(AIAgent=FakeAgent) + ) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"model": {"default": "gpt-test", "provider": "openai"}}, + ) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **_kwargs: { + "api_key": "key", + "base_url": "https://example.invalid", + "provider": "openai", + "api_mode": "chat_completions", + "credential_pool": None, + }, + ) + monkeypatch.setattr(oneshot_mod, "_create_session_db_for_oneshot", lambda: None) + + assert ( + oneshot_mod._run_agent( + "hello", model="gpt-test", provider="openai", use_config_toolsets=False + ) + == ("done", {"final_response": "done"}) + ) + assert closed == [True] + assert shutdown_messages == [[{"role": "user", "content": "hello"}]] + + +def test_oneshot_run_agent_closes_agent_when_chat_raises(monkeypatch): + import hermes_cli.oneshot as oneshot_mod + + closed = [] + shutdowns = [] + + class FakeAgent: + def __init__(self, **_kwargs): + self.suppress_status_output = False + self.stream_delta_callback = object() + self.tool_gen_callback = object() + + def run_conversation(self, _prompt, **_kwargs): + raise RuntimeError("boom") + + def shutdown_memory_provider(self, messages=None): + shutdowns.append(messages) + + def close(self): + closed.append(True) + + monkeypatch.setitem( + sys.modules, "run_agent", types.SimpleNamespace(AIAgent=FakeAgent) + ) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"model": {"default": "gpt-test", "provider": "openai"}}, + ) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **_kwargs: { + "api_key": "key", + "base_url": "https://example.invalid", + "provider": "openai", + "api_mode": "chat_completions", + "credential_pool": None, + }, + ) + monkeypatch.setattr(oneshot_mod, "_create_session_db_for_oneshot", lambda: None) + + with pytest.raises(RuntimeError, match="boom"): + oneshot_mod._run_agent( + "hello", model="gpt-test", provider="openai", use_config_toolsets=False + ) + assert closed == [True] + assert shutdowns == [None] + + +def test_oneshot_run_agent_closes_session_db(monkeypatch): + # The one-shot exit path hard-exits via os._exit and skips finalizers, so + # the recall SQLite store it opens must be closed explicitly (checkpointing + # its WAL) rather than left to interpreter teardown. + import hermes_cli.oneshot as oneshot_mod + + db_closed = [] + + class FakeAgent: + def __init__(self, **_kwargs): + self.suppress_status_output = False + self.stream_delta_callback = object() + self.tool_gen_callback = object() + self._session_messages = [] + + def run_conversation(self, _prompt, **_kwargs): + return {"final_response": "done"} + + def shutdown_memory_provider(self, messages=None): + pass + + def close(self): + pass + + class FakeSessionDB: + def close(self): + db_closed.append(True) + + monkeypatch.setitem( + sys.modules, "run_agent", types.SimpleNamespace(AIAgent=FakeAgent) + ) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"model": {"default": "gpt-test", "provider": "openai"}}, + ) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **_kwargs: { + "api_key": "key", + "base_url": "https://example.invalid", + "provider": "openai", + "api_mode": "chat_completions", + "credential_pool": None, + }, + ) + monkeypatch.setattr( + oneshot_mod, "_create_session_db_for_oneshot", lambda: FakeSessionDB() + ) + + assert ( + oneshot_mod._run_agent( + "hello", model="gpt-test", provider="openai", use_config_toolsets=False + ) + == ("done", {"final_response": "done"}) + ) + assert db_closed == [True] + + +def test_oneshot_run_agent_closes_session_db_when_agent_init_raises(monkeypatch): + # The recall store is opened before AIAgent is constructed. If construction + # raises (bad provider/config/model), the store must still be closed — the + # one-shot exit hard-exits via os._exit and skips finalizers, so an + # un-closed connection would leave a stale WAL behind. + import hermes_cli.oneshot as oneshot_mod + + db_closed = [] + + class FakeSessionDB: + def close(self): + db_closed.append(True) + + class FakeAgent: + def __init__(self, **_kwargs): + raise RuntimeError("init boom") + + monkeypatch.setitem( + sys.modules, "run_agent", types.SimpleNamespace(AIAgent=FakeAgent) + ) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"model": {"default": "gpt-test", "provider": "openai"}}, + ) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **_kwargs: { + "api_key": "key", + "base_url": "https://example.invalid", + "provider": "openai", + "api_mode": "chat_completions", + "credential_pool": None, + }, + ) + monkeypatch.setattr( + oneshot_mod, "_create_session_db_for_oneshot", lambda: FakeSessionDB() + ) + + with pytest.raises(RuntimeError, match="init boom"): + oneshot_mod._run_agent( + "hello", model="gpt-test", provider="openai", use_config_toolsets=False + ) + + assert db_closed == [True] + + def _stub_plugin_discovery(monkeypatch): monkeypatch.setitem( sys.modules,
-
-
#109328 Honcho gateway identity: wizard copy, 'peers map', sessionAiPeerPrefix (salvage #83981, #39130)
files:
contributors/emails/[redacted:email],contributors/emails/[redacted:email],contributors/emails/[redacted:email],contributors/emails/[redacted:email],plugins/memory/honcho/README.md,plugins/memory/honcho/__init__.py,plugins/memory/honcho/cli.py,plugins/memory/honcho/client.py,plugins/memory/honcho/config_schema.py,plugins/memory/honcho/dialectic.py,plugins/memory/honcho/recall_sync.py,plugins/memory/honcho/session.py,plugins/memory/honcho/session_context.py,plugins/memory/honcho/session_migration.py,plugins/memory/honcho/session_peers.py,tests/agent/test_memory_user_id.py,tests/honcho_plugin/test_a2a_sessions.py,tests/honcho_plugin/test_async_memory.py,tests/honcho_plugin/test_auth_recovery.py,tests/honcho_plugin/test_cli.py(+21 more)
7 PRs closed without merging
From the close comments: done elsewhere 5 · PR volume cleanup 1 · other 1
-
#11680 fix: cleanly shut down Honcho background workers · done elsewhere →
924bc67 -
#33543 fix(cli): prevent SIGABRT on hermes -z process exit · done elsewhere →
244dabb,#67905 -
#33701 fix(memory/honcho): align shutdown join timeout with Honcho HTTP timeout · done elsewhere →
476dbfe,#109328 -
#37635 fix(oneshot): join memory daemon threads before process exit (#37632) · done elsewhere →
bfa7a79,244dabb,7e53b3e,#67905 -
#43698 fix(cli): avoid one-shot SIGABRT during teardown (#43055) · other →
#67905
Closed issues · 3
- Closed#30387Bug: hermes -z prints successful response then aborts during shutdown with exit 134
- Closed#33485[Bug]: Honcho hybrid memory can leave daemon dialectic threads alive during CLI shutdown, causing intermittent CPython abort
- Closed#60616hermes -z (--oneshot) crashes with SIGABRT after response, only when memory.provider=honcho
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.15: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab