Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 4 additions & 19 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10325,25 +10325,10 @@ def cmd_dashboard(args):
# the missing-provider state if it matters.
print(f"⚠ Plugin discovery failed: {exc}", file=sys.stderr)

# Desktop chat uses the dashboard's in-process /api/ws gateway, which builds
# agents via tui_gateway.server._make_agent. That path only snapshots the
# tool registry — it never starts MCP discovery (the stdio TUI does that in
# tui_gateway/entry.py, which the dashboard process doesn't run). Without
# this, a profile's configured MCP servers never connect, so desktop
# sessions show no MCP tools. Spawn discovery in the background here so a
# slow/dead server can't block dashboard startup.
try:
from hermes_cli.mcp_startup import start_background_mcp_discovery

start_background_mcp_discovery(
logger=logger,
thread_name="dashboard-mcp-discovery",
)
except Exception:
logger.debug(
"Background MCP tool discovery failed at dashboard startup",
exc_info=True,
)
# Keep an unvisited dashboard disk-idle: MCP discovery may launch stdio
# servers and their stderr log streams. The dashboard chat WebSocket starts
# discovery on first use before agent construction, preserving MCP tools
# without running the MCP stack in an idle management server.

from hermes_cli.web_server import start_server

Expand Down
17 changes: 11 additions & 6 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -2509,8 +2509,10 @@ def close(self):
"""Close the database connection.

Drains queued token deltas first (the background writer needs the
connection), then attempts a TRUNCATE WAL checkpoint so that
exiting processes help shrink the WAL file.
connection). Writable connections then attempt a TRUNCATE WAL
checkpoint so that exiting processes help shrink the WAL file.
Read-only probes close without checkpointing, preserving their
no-write contract.
"""
self._stop_token_writer()
# The atexit hook holds a strong reference to this instance (bound
Expand Down Expand Up @@ -2538,10 +2540,13 @@ def close(self):
self._read_local.conn = None
with self._lock:
if self._conn:
try:
self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except Exception as exc:
logger.debug("WAL checkpoint (TRUNCATE) at close failed: %s", exc)
if not self.read_only:
try:
self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except Exception as exc:
logger.debug(
"WAL checkpoint (TRUNCATE) at close failed: %s", exc
)
self._conn.close()
self._conn = None

Expand Down
36 changes: 36 additions & 0 deletions tests/hermes_cli/test_dashboard_unified_launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,42 @@ def test_desktop_profile_backend_skips_machine_dashboard_reroute(self, main_mod,
assert listening_calls == []
assert execs == []

def test_dashboard_defers_mcp_discovery_until_ws_backend(self, main_mod, monkeypatch):
"""An unvisited dashboard must not launch MCP servers at startup.

The /api/ws sidecar starts discovery on first chat connection, before
agent construction, so MCP tools are still available without keeping an
idle dashboard writing mcp-stderr.log.
"""
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "default"
)
monkeypatch.delenv("HERMES_WEB_DIST", raising=False)
monkeypatch.setattr(main_mod, "_sync_bundled_skills_quietly", lambda: None)
monkeypatch.setattr(main_mod, "_build_web_ui", lambda *_a, **_k: True)
monkeypatch.setitem(sys.modules, "fastapi", types.SimpleNamespace())
monkeypatch.setitem(sys.modules, "uvicorn", types.SimpleNamespace())
monkeypatch.setitem(
sys.modules,
"hermes_logging",
types.SimpleNamespace(setup_logging=lambda **_k: None),
)
monkeypatch.setitem(
sys.modules,
"hermes_cli.plugins",
types.SimpleNamespace(discover_plugins=lambda: None),
)
calls = []
monkeypatch.setattr(
"hermes_cli.mcp_startup.start_background_mcp_discovery",
lambda **kwargs: calls.append(kwargs),
)
monkeypatch.setitem(
sys.modules,
"hermes_cli.web_server",
types.SimpleNamespace(start_server=lambda **_kwargs: None),
)

main_mod.cmd_dashboard(_args())

assert calls == []
1 change: 0 additions & 1 deletion tests/hermes_cli/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3517,4 +3517,3 @@ async def get(self, *args, **kwargs):
assert self.ws.DASHBOARD_HEALTH.selftest_http_status == 500
assert self.ws.DASHBOARD_HEALTH.snapshot()["status"] == "degraded"


27 changes: 27 additions & 0 deletions tests/test_hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -2135,6 +2135,33 @@ def _assume_fixed_sqlite(self, monkeypatch):
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False
)

def test_read_only_close_does_not_checkpoint(self, tmp_path, monkeypatch):
"""Read-only dashboard probes must not trigger WAL checkpoints on close."""

class _FakeConn:
def __init__(self):
self.executed = []
self.closed = False
self.row_factory = None

def execute(self, sql, params=()):
self.executed.append(sql)
return self

def fetchone(self):
return None

def close(self):
self.closed = True

conn = _FakeConn()
monkeypatch.setattr(hermes_state.sqlite3, "connect", lambda *a, **kw: conn)

session_db = SessionDB(db_path=tmp_path / "state.db", read_only=True)
session_db.close()

assert conn.closed is True
assert not any("wal_checkpoint" in sql for sql in conn.executed)

def test_sets_wal_on_fresh_connection(self, tmp_path):
"""Probe sees 'delete', then set-pragma runs and returns 'wal'."""
Expand Down
Loading