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

3open issues
0open PRs
15items grouped
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.

These 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

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #67905 fix(cli): prevent SIGABRT (exit 134) during hermes -z oneshot teardown

    merged to main

    files: hermes_cli/main.py, hermes_cli/oneshot.py, tests/hermes_cli/test_tui_resume_flow.py

    Show 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)

    merged to main

    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
Show 2 more
  • #62550 fix(cli): shut down memory provider in oneshot mode to prevent SIGABRT · done elsewhere → #67905, #109328, #60616
  • #67768 fix(cli): clean up oneshot resources before exit · PR volume cleanup
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.00 means 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

At a glance

Subsystem
agent
Merged PRs
#67905, #109328
Closed issues
3
Attempts
7 PRs closed without merging
Fix link checked
13 Sep 2026

For your agent

The same facts, typed.