Files
hermes-agent/tests/agent/lsp/test_manager_locking.py
kshitijk4poor e929702021 fix(lsp): trust the workspace the session was opened in, not only the process cwd
The trust anchor was os.getcwd(), which is the user's project only for a
plain `hermes` launched inside a repo. `hermes -w` sets TERMINAL_CWD to its
new worktree without a chdir, the Desktop/TUI backend runs from the install
tree or $HOME with the project bound as the session cwd, the gateway runs
from HERMES_HOME, and cron binds a per-job workdir. All of them lost
pyright's project venv, rust-analyzer/gopls/jdtls/... and the .ts/.rs lint
fallbacks inside the user's own repository.

operator_workspace_roots() now adds the worktree of resolve_agent_cwd()
(session cwd, then TERMINAL_CWD) to the launch dir's; only surfaces set
those, the agent's `cd` moves the terminal env's cwd, not them. $HOME is
never an anchor: a dotfiles repo there would trust every directory below it.
The service remembers every anchor it has seen, because the loop thread
that spawns servers has no session context of its own.

- The lint gate checks only the linter's cwd: npx and rustup resolve the
  toolchain from there (subprocess cwd=env.cwd), not from the file's dir.
  The try/except that wrapped code which cannot fail is gone.
- Trust is computed once per spawn and handed to ServerContext.
- Multi-root servers (pyright) share one process across trusted roots
  only; an untrusted root gets its own, so whichever root spawned first no
  longer decides the interpreter for the others.
2026-09-29 03:28:46 +05:30

100 lines
3.8 KiB
Python

"""Concurrency invariants for the LSP service state lock."""
from __future__ import annotations
import asyncio
import agent.lsp.manager as manager
from agent.lsp.manager import LSPService
from agent.lsp.servers import ServerDef
def test_reused_multiroot_client_attaches_outside_state_lock(monkeypatch):
"""Awaitable multi-root attachment must never run while ``_state_lock`` is held.
A synchronous ``threading.Lock`` held across an await can deadlock the single
LSP event-loop thread as soon as another coroutine tries to acquire that lock.
"""
service = LSPService(
enabled=False,
wait_mode="document",
wait_timeout=0.1,
install_strategy="manual",
idle_timeout=0,
)
root = "/repo/worktree-b"
server = ServerDef(
server_id="pyright",
extensions=(".py",),
resolve_root=lambda _file_path, _workspace_root: root,
build_spawn=lambda _root, _ctx: None,
multi_root=True,
)
class StubClient:
is_running = True
async def add_workspace_folder(self, attached_root: str) -> None:
assert attached_root == root
lock_was_free = service._state_lock.acquire(blocking=False)
if lock_was_free:
service._state_lock.release()
assert lock_was_free, (
"_state_lock must be released before awaiting workspace attachment"
)
client = StubClient()
monkeypatch.setattr(service, "_trusted", lambda _root: True) # trusted roots share the process
service._clients[(server.server_id, "")] = client # multi-root client key
service._last_used[(server.server_id, "")] = 0.0
monkeypatch.setattr(manager, "find_server_for_file", lambda _path: server)
monkeypatch.setattr(
manager,
"resolve_workspace_for_file",
lambda _path: ("/repo", True),
)
monkeypatch.setattr(manager.eventlog, "log_active", lambda *_args, **_kwargs: None)
result = asyncio.run(service._get_or_spawn("/repo/worktree-b/example.py"))
assert result is client
def test_delta_baseline_is_capped_by_write_recency(monkeypatch):
"""Driven through the production entry points (``snapshot_baseline`` before a write, ``_apply_delta``
rolling the baseline forward after one): baselines beyond _DELTA_BASELINE_CAP evict the path
written longest ago, and re-touching a path refreshes it instead of aging it out."""
service = LSPService(enabled=False, wait_mode="document", wait_timeout=0.1,
install_strategy="manual", idle_timeout=0)
monkeypatch.setattr(service, "enabled_for", lambda _p: True)
server_diags: list = []
class _Loop: # stands in for the (unstarted) background loop; returns the server's current diagnostics
def run(self, coro, timeout=None):
coro.close()
return list(server_diags)
monkeypatch.setattr(service, "_loop", _Loop())
class _LockedDict(dict): # every mutation of the shared baseline dict must run under _state_lock
def __setitem__(self, k, v):
assert service._state_lock.locked()
super().__setitem__(k, v)
def __delitem__(self, k):
assert service._state_lock.locked()
super().__delitem__(k)
service._delta_baseline = _LockedDict()
cap = manager._DELTA_BASELINE_CAP
for i in range(cap):
service.snapshot_baseline(f"/repo/f{i}.py")
server_diags.append({"message": "rewritten"})
assert service._apply_delta("/repo/f0.py", [{"message": "rewritten"}], None) == [{"message": "rewritten"}]
server_diags.clear()
service.snapshot_baseline("/repo/new.py")
assert len(service._delta_baseline) == cap
assert "/repo/f1.py" not in service._delta_baseline
assert service._delta_baseline["/repo/f0.py"] == [{"message": "rewritten"}]
assert "/repo/new.py" in service._delta_baseline