Files
hermes-agent/agent/lsp/__init__.py
teknium1 d6f1de3f74 fix(lsp): release a worktree's language servers on removal; reap deleted roots
`LSPService` kept one client per `(server_id, workspace_root)` for the life
of the process.  In a long-running gateway that outlives its coding sessions
the client for a removed worktree stayed registered with its stdio pipes
held open — tsserver heaps of several GiB pointed at trees that no longer
existed (#102345).  The idle reaper (d7578018c5) does not cover this: a
client whose root vanished is not idle from the server's point of view.

- `LSPService.release_workspace(path)`: detaches every client whose folders
  live under `path` under `_state_lock`, waits for in-flight spawns so a
  concurrent `_get_or_spawn` cannot reinsert a client after release, prunes
  the delta baselines and broken-set entries beneath the path, and shuts the
  clients down on the service loop.  Multi-root servers (pyright) only drop
  the folder (`LSPClient.remove_workspace_folder`) so siblings keep their
  shared process.  Idempotent; best-effort; returns the count.
- The reaper sweep now also uses that primitive for clients whose every
  workspace folder no longer exists (externally deleted roots).
- `agent.lsp.release_workspace()` reaches every started service without
  creating one; `hermes_cli.worktree_ops.release_lsp_clients()` is called
  from both worktree-removal paths (`cli._cleanup_worktree`, kanban
  `_cleanup_worktree_workspace`) BEFORE `git worktree remove`.

Salvages the direction of #102381 (@Sahilvishnaliya, release_workspace +
cli hook) and the deleted-root eligibility of #95047 (@israellot); both
matched on `key[1]`, which is `""` for multi-root servers and would have
reaped pyright on every sweep — the primitive here matches on
`client.workspace_folders` instead.

Co-authored-by: Sahil Vishnalya <222165401+Sahilvishnaliya@users.noreply.github.com>
Co-authored-by: Israel Lot <840042+israellot@users.noreply.github.com>
2026-09-19 01:29:03 -07:00

104 lines
3.9 KiB
Python

"""Language Server Protocol (LSP) integration for Hermes Agent.
Real language servers (pyright, gopls, ...) run as subprocesses and their
``publishDiagnostics`` feed the post-write lint delta filter of ``write_file`` /
``patch`` (wiring: ``FileOperations._check_lint_delta``). LSP is **gated on git
workspace detection** so user-home cwd's (e.g. Telegram gateway chats) never
spawn daemons; ``get_service()`` returns the singleton or ``None`` when disabled.
"""
from __future__ import annotations
import atexit
import logging
import threading
from typing import Optional
from agent.lsp.manager import LSPService
logger = logging.getLogger("agent.lsp")
_service: Optional[LSPService] = None
# Routed multiplex profiles (HERMES_HOME override) each get their own service: ``lsp.*`` config
# (enabled, servers, idle timeout) is per profile, so one process-wide singleton would let the first
# profile's settings decide whether every other profile gets diagnostics.
_services_by_home: dict = {}
_atexit_registered = False
_service_lock = threading.Lock()
def _active(svc: Optional[LSPService]) -> Optional[LSPService]:
return svc if (svc is not None and svc.is_active()) else None
def _register_atexit_once() -> None:
global _atexit_registered
if not _atexit_registered:
atexit.register(_atexit_shutdown)
_atexit_registered = True
def get_service() -> Optional[LSPService]:
"""Return the lazily created LSP service for the active profile (process-wide singleton when no
profile override is bound), or None when disabled.
Also registers an :mod:`atexit` hook so a clean exit tears down spawned servers:
without it every ``hermes chat`` exit leaks pyright processes for a few seconds
while their stdout buffers drain. (SIGKILL/os._exit skip atexit — fine, the
kernel reaps the stateless servers with their parent.)
"""
global _service
from hermes_constants import get_hermes_home_override, hermes_home_key
if get_hermes_home_override() is not None:
home_key = hermes_home_key()
with _service_lock:
if home_key not in _services_by_home:
_services_by_home[home_key] = LSPService.create_from_config()
_register_atexit_once()
return _active(_services_by_home[home_key])
if _service is None:
with _service_lock:
if _service is None:
_service = LSPService.create_from_config()
_register_atexit_once()
return _active(_service)
def release_workspace(path: str) -> int:
"""Shut down the LSP clients serving ``path`` (a worktree about to be removed) in every started
service, without shutting down unrelated workspaces. Never creates a service. Returns the count."""
with _service_lock:
services = [svc for svc in (_service, *_services_by_home.values()) if svc is not None]
released = 0
for svc in services:
try:
released += svc.release_workspace(path)
except Exception as e: # noqa: BLE001
logger.debug("LSP workspace release failed for %s: %s", path, e)
return released
def shutdown_service() -> None:
"""Tear down every LSP service that was started. Idempotent."""
global _service
with _service_lock:
services = [_service, *_services_by_home.values()]
_service = None
_services_by_home.clear()
for svc in services:
if svc is not None:
try:
svc.shutdown()
except Exception as e: # noqa: BLE001
logger.debug("LSP shutdown error: %s", e)
def _atexit_shutdown() -> None:
"""atexit wrapper; logs at debug since the user has already seen the final output."""
try:
shutdown_service()
except Exception as e: # noqa: BLE001
logger.debug("atexit LSP shutdown failed: %s", e)
__all__ = ["get_service", "release_workspace", "shutdown_service", "LSPService"]