From 05809e70a8474591839f7e2274b3ab258199f1d6 Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Tue, 29 Sep 2026 12:58:26 +0300 Subject: [PATCH] feat(process): mcp exe hardlink fallback for windowless pythonw --- install.py | 34 ++++++++++++++++++- src/core/process_titles.py | 64 +++++++++++++++++++++++++++++----- tests/test_process_titles.py | 66 ++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 10 deletions(-) diff --git a/install.py b/install.py index 90f7b5c9..ed43025e 100644 --- a/install.py +++ b/install.py @@ -61,7 +61,11 @@ IS_WINDOWS = sys.platform == "win32" PYTHON_EXE = VENV_DIR / "Scripts" / "python.exe" if IS_WINDOWS else VENV_DIR / "bin" / "python3" # Запуск MCP из расширения без окна консоли (pythonw, инцидент 2026-08-14) +# Task Manager показывает имя exe (у pythonw нет консоли для title, PR #59), +# поэтому MCP запускается через NTFS-хардлинк mscodebase-mcp.exe → pythonw.exe +# (тот же приём, что llama-embed/llama-rerank; см. src/core/process_titles.py). MCP_PYTHON = VENV_DIR / "Scripts" / "pythonw.exe" if IS_WINDOWS else PYTHON_EXE +MCP_EXE = VENV_DIR / "Scripts" / "mscodebase-mcp.exe" if IS_WINDOWS else PYTHON_EXE VENV_SITE_PACKAGES = ( VENV_DIR / "Lib" / "site-packages" if IS_WINDOWS @@ -295,6 +299,28 @@ def _fix_ghosts() -> int: return len(candidates) +def _ensure_mcp_hardlink() -> Path: + """Создаёт (once) NTFS-хардлинк mscodebase-mcp.exe → pythonw.exe. + + Тот же приём, что llama-embed/llama-rerank: os.link (та же inode, + без копии). Молча пропускает при любой ошибке — вызовы падают + назад на plain pythonw.exe и установка не роняется. + """ + try: + sys.path.insert(0, str(PROJECT_ROOT)) + from src.core.process_titles import ensure_mcp_hardlink + + return ensure_mcp_hardlink(MCP_PYTHON) + except Exception as e: + logger.debug("_ensure_mcp_hardlink failed: %s", e) + return MCP_PYTHON + finally: + try: + sys.path.remove(str(PROJECT_ROOT)) + except ValueError: + pass + + # ─── Process cleanup (cross-platform, wmic-free) ─────────── def _kill_by_cmdline_windows(pattern: str) -> int: """Убивает процессы, чья командная строка содержит pattern. @@ -526,6 +552,7 @@ def step_venv(lines, lang): if VENV_DIR.exists() and PYTHON_EXE.exists(): r = _run(f'"{PYTHON_EXE}" --version', timeout=10) if r and r.returncode == 0: + _ensure_mcp_hardlink() lines.append((C.GRN, f"✓ {VENV_DIR}")) return lines.append((C.YEL, "⚠ venv found but broken, recreating")) @@ -533,6 +560,7 @@ def step_venv(lines, lang): r = _run(f'"{sys.executable}" -m venv "{VENV_DIR}"', timeout=60) if r and r.returncode == 0 and PYTHON_EXE.exists(): + _ensure_mcp_hardlink() lines.append((C.GRN, f"✓ {VENV_DIR}")) else: raise RuntimeError("venv creation failed") @@ -803,7 +831,11 @@ def step_zedcfg(lines, lang): # install.py НАСТРАИВАЕТ MCP в settings.json Zed через patch_zed_settings(). # Явный путь к venv РАСШИРЕНИЯ (не command=None — get_python_path вернул бы # venv проекта) + pythonw.exe без окна консоли. Инцидент 2026-08-14. - cmd = f"{MCP_PYTHON} -u -m src.main" + # Имя exe — то, что показывает Task Manager (у pythonw нет консоли для + # title): запускаемся через mscodebase-mcp.exe-хардлинк → pythonw.exe, + # fallback — plain pythonw при ошибке создания линка. + mcp_exe = str(_ensure_mcp_hardlink()) if IS_WINDOWS else str(MCP_PYTHON) + cmd = f"{mcp_exe} -u -m src.main" if patch_zed_settings( cmd, mode="global", diff --git a/src/core/process_titles.py b/src/core/process_titles.py index 2e65a8ad..fdd37e95 100644 --- a/src/core/process_titles.py +++ b/src/core/process_titles.py @@ -17,6 +17,8 @@ MCP_TITLE_PREFIX = "mscodebase-mcp" +MCP_EXE_NAME = "mscodebase-mcp.exe" + ROLE_BIN_NAMES = { "embed": "llama-embed.exe", "rerank": "llama-rerank.exe", @@ -80,19 +82,15 @@ def role_bin_name(role: str, default_name: str = "llama-server.exe") -> str: return ROLE_BIN_NAMES.get(role, default_name) -def ensure_role_hardlink(bin_path: str | Path, role: str) -> Path: - """Create (once) an NTFS hardlink ``llama-.exe`` → ``bin_path``. +def _ensure_hardlink(src: Path, link_name: str) -> Path: + """Shared NTFS-hardlink helper (os.link, same inode, never raises). - Uses ``os.link`` (same inode, no 100MB+ copy). If the link already - exists, it is reused as-is. On any failure (permissions, FS without - hardlink support) logs and returns the original ``bin_path`` — - callers must fall back to plain spawn and never crash startup. + If ``link_name`` already exists next to ``src`` it is reused as-is. + On any failure returns ``src`` so callers fall back to plain spawn. """ - src = Path(bin_path) try: if sys.platform != "win32": return src - link_name = ROLE_BIN_NAMES.get(role) if not link_name: return src link = src.parent / link_name @@ -106,10 +104,58 @@ def ensure_role_hardlink(bin_path: str | Path, role: str) -> Path: return src return link except Exception as e: # noqa: BLE001 - logger.warning(f"ensure_role_hardlink failed ({e}); using {src}") + logger.warning(f"_ensure_hardlink failed ({e}); using {src}") + return src + + +def ensure_role_hardlink(bin_path: str | Path, role: str) -> Path: + """Create (once) an NTFS hardlink ``llama-.exe`` → ``bin_path``. + + Uses ``os.link`` (same inode, no 100MB+ copy). If the link already + exists, it is reused as-is. On any failure (permissions, FS without + hardlink support) logs and returns the original ``bin_path`` — + callers must fall back to plain spawn and never crash startup. + """ + src = Path(bin_path) + link_name = ROLE_BIN_NAMES.get(role) + if not link_name: return src + return _ensure_hardlink(src, link_name) def resolve_llama_role_bin(bin_path: str | Path, role: str) -> str: """Resolve the executable path to spawn for a llama role (str).""" return str(ensure_role_hardlink(bin_path, role)) + + +def mcp_exe_name(default_name: str = "pythonw.exe") -> str: + """Return the MCP hardlink file name (``mscodebase-mcp.exe`` on win32). + + Falls back to ``default_name`` off-Windows so POSIX spawns keep + using the venv interpreter unchanged. + """ + if sys.platform != "win32": + return default_name + return MCP_EXE_NAME + + +def ensure_mcp_hardlink(pythonw_path: str | Path) -> Path: + """Create (once) an NTFS hardlink ``mscodebase-mcp.exe`` → ``pythonw``. + + Same pattern as :func:`ensure_role_hardlink` (os.link, same inode, + no copy). Console-title approach cannot work because the MCP runs + under windowless ``pythonw.exe`` (no console to title) — the exe + name is what Task Manager shows. Never raises: on any failure + returns the original ``pythonw_path`` for plain-spawn fallback. + """ + return _ensure_hardlink(Path(pythonw_path), MCP_EXE_NAME) + + +def resolve_mcp_exe(pythonw_path: str | Path) -> str: + """Resolve the executable path to spawn for the MCP (str). + + Returns the ``mscodebase-mcp.exe`` hardlink when it exists (or can + be created); otherwise falls back to the given ``pythonw_path`` — + e.g. when the hardlink is missing and creation failed. + """ + return str(ensure_mcp_hardlink(pythonw_path)) diff --git a/tests/test_process_titles.py b/tests/test_process_titles.py index 2297a645..7a8d5ad1 100644 --- a/tests/test_process_titles.py +++ b/tests/test_process_titles.py @@ -89,3 +89,69 @@ def test_ensure_role_hardlink_non_win_no_link(tmp_path): with patch.object(sys, "platform", "linux"): with patch("os.link", side_effect=AssertionError("must not be called")): assert pt.ensure_role_hardlink(src, "embed") == src + + +def test_mcp_exe_name_win(): + from src.core import process_titles as pt + + with patch.object(sys, "platform", "win32"): + assert pt.mcp_exe_name() == "mscodebase-mcp.exe" + + +def test_mcp_exe_name_non_win_fallback(): + from src.core import process_titles as pt + + with patch.object(sys, "platform", "linux"): + assert pt.mcp_exe_name() == "pythonw.exe" + assert pt.mcp_exe_name("python3") == "python3" + + +def test_ensure_mcp_hardlink_mock_link(tmp_path): + from src.core import process_titles as pt + + src = tmp_path / "pythonw.exe" + src.write_bytes(b"x") + with patch.object(sys, "platform", "win32"): + created = [] + + def fake_link(a, b): + created.append((a, b)) + Path(b).write_bytes(b"x") + + with patch("os.link", side_effect=fake_link): + out = pt.ensure_mcp_hardlink(src) + assert out == tmp_path / "mscodebase-mcp.exe" + assert out.exists() + assert created == [(str(src), str(tmp_path / "mscodebase-mcp.exe"))] + + +def test_ensure_mcp_hardlink_fallback_on_error(tmp_path): + from src.core import process_titles as pt + + src = tmp_path / "pythonw.exe" + src.write_bytes(b"x") + with patch.object(sys, "platform", "win32"): + with patch("os.link", side_effect=OSError("denied")): + assert pt.ensure_mcp_hardlink(src) == src + + +def test_resolve_mcp_exe_fallback_when_link_missing(tmp_path): + from src.core import process_titles as pt + + src = tmp_path / "pythonw.exe" + src.write_bytes(b"x") + with patch.object(sys, "platform", "win32"): + with patch("os.link", side_effect=OSError("denied")): + assert pt.resolve_mcp_exe(src) == str(src) + + +def test_resolve_mcp_exe_reuses_existing(tmp_path): + from src.core import process_titles as pt + + src = tmp_path / "pythonw.exe" + src.write_bytes(b"x") + link = tmp_path / "mscodebase-mcp.exe" + link.write_bytes(b"x") + with patch.object(sys, "platform", "win32"): + with patch("os.link", side_effect=AssertionError("must not be called")): + assert pt.resolve_mcp_exe(src) == str(link)