stravinsky 0.2.67__py3-none-any.whl → 0.4.66__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of stravinsky might be problematic. Click here for more details.
- mcp_bridge/__init__.py +1 -1
- mcp_bridge/auth/__init__.py +16 -6
- mcp_bridge/auth/cli.py +202 -11
- mcp_bridge/auth/oauth.py +1 -2
- mcp_bridge/auth/openai_oauth.py +4 -7
- mcp_bridge/auth/token_store.py +112 -11
- mcp_bridge/cli/__init__.py +1 -1
- mcp_bridge/cli/install_hooks.py +503 -107
- mcp_bridge/cli/session_report.py +0 -3
- mcp_bridge/config/MANIFEST_SCHEMA.md +305 -0
- mcp_bridge/config/README.md +276 -0
- mcp_bridge/config/__init__.py +2 -2
- mcp_bridge/config/hook_config.py +247 -0
- mcp_bridge/config/hooks_manifest.json +138 -0
- mcp_bridge/config/rate_limits.py +317 -0
- mcp_bridge/config/skills_manifest.json +128 -0
- mcp_bridge/hooks/HOOKS_SETTINGS.json +17 -4
- mcp_bridge/hooks/__init__.py +19 -4
- mcp_bridge/hooks/agent_reminder.py +4 -4
- mcp_bridge/hooks/auto_slash_command.py +5 -5
- mcp_bridge/hooks/budget_optimizer.py +2 -2
- mcp_bridge/hooks/claude_limits_hook.py +114 -0
- mcp_bridge/hooks/comment_checker.py +3 -4
- mcp_bridge/hooks/compaction.py +2 -2
- mcp_bridge/hooks/context.py +2 -1
- mcp_bridge/hooks/context_monitor.py +2 -2
- mcp_bridge/hooks/delegation_policy.py +85 -0
- mcp_bridge/hooks/directory_context.py +3 -3
- mcp_bridge/hooks/edit_recovery.py +3 -2
- mcp_bridge/hooks/edit_recovery_policy.py +49 -0
- mcp_bridge/hooks/empty_message_sanitizer.py +2 -2
- mcp_bridge/hooks/events.py +160 -0
- mcp_bridge/hooks/git_noninteractive.py +4 -4
- mcp_bridge/hooks/keyword_detector.py +8 -10
- mcp_bridge/hooks/manager.py +43 -22
- mcp_bridge/hooks/notification_hook.py +13 -6
- mcp_bridge/hooks/parallel_enforcement_policy.py +67 -0
- mcp_bridge/hooks/parallel_enforcer.py +5 -5
- mcp_bridge/hooks/parallel_execution.py +22 -10
- mcp_bridge/hooks/post_tool/parallel_validation.py +103 -0
- mcp_bridge/hooks/pre_compact.py +8 -9
- mcp_bridge/hooks/pre_tool/agent_spawn_validator.py +115 -0
- mcp_bridge/hooks/preemptive_compaction.py +2 -3
- mcp_bridge/hooks/routing_notifications.py +80 -0
- mcp_bridge/hooks/rules_injector.py +11 -19
- mcp_bridge/hooks/session_idle.py +4 -4
- mcp_bridge/hooks/session_notifier.py +4 -4
- mcp_bridge/hooks/session_recovery.py +4 -5
- mcp_bridge/hooks/stravinsky_mode.py +1 -1
- mcp_bridge/hooks/subagent_stop.py +1 -3
- mcp_bridge/hooks/task_validator.py +2 -2
- mcp_bridge/hooks/tmux_manager.py +7 -8
- mcp_bridge/hooks/todo_delegation.py +4 -1
- mcp_bridge/hooks/todo_enforcer.py +180 -10
- mcp_bridge/hooks/tool_messaging.py +113 -10
- mcp_bridge/hooks/truncation_policy.py +37 -0
- mcp_bridge/hooks/truncator.py +1 -2
- mcp_bridge/metrics/cost_tracker.py +115 -0
- mcp_bridge/native_search.py +93 -0
- mcp_bridge/native_watcher.py +118 -0
- mcp_bridge/notifications.py +150 -0
- mcp_bridge/orchestrator/enums.py +11 -0
- mcp_bridge/orchestrator/router.py +165 -0
- mcp_bridge/orchestrator/state.py +32 -0
- mcp_bridge/orchestrator/visualization.py +14 -0
- mcp_bridge/orchestrator/wisdom.py +34 -0
- mcp_bridge/prompts/__init__.py +1 -8
- mcp_bridge/prompts/dewey.py +1 -1
- mcp_bridge/prompts/planner.py +2 -4
- mcp_bridge/prompts/stravinsky.py +53 -31
- mcp_bridge/proxy/__init__.py +0 -0
- mcp_bridge/proxy/client.py +70 -0
- mcp_bridge/proxy/model_server.py +157 -0
- mcp_bridge/routing/__init__.py +43 -0
- mcp_bridge/routing/config.py +250 -0
- mcp_bridge/routing/model_tiers.py +135 -0
- mcp_bridge/routing/provider_state.py +261 -0
- mcp_bridge/routing/task_classifier.py +190 -0
- mcp_bridge/server.py +542 -59
- mcp_bridge/server_tools.py +738 -6
- mcp_bridge/tools/__init__.py +40 -25
- mcp_bridge/tools/agent_manager.py +616 -697
- mcp_bridge/tools/background_tasks.py +13 -17
- mcp_bridge/tools/code_search.py +70 -53
- mcp_bridge/tools/continuous_loop.py +0 -1
- mcp_bridge/tools/dashboard.py +19 -0
- mcp_bridge/tools/find_code.py +296 -0
- mcp_bridge/tools/init.py +1 -0
- mcp_bridge/tools/list_directory.py +42 -0
- mcp_bridge/tools/lsp/__init__.py +12 -5
- mcp_bridge/tools/lsp/manager.py +471 -0
- mcp_bridge/tools/lsp/tools.py +723 -207
- mcp_bridge/tools/model_invoke.py +1195 -273
- mcp_bridge/tools/mux_client.py +75 -0
- mcp_bridge/tools/project_context.py +1 -2
- mcp_bridge/tools/query_classifier.py +406 -0
- mcp_bridge/tools/read_file.py +84 -0
- mcp_bridge/tools/replace.py +45 -0
- mcp_bridge/tools/run_shell_command.py +38 -0
- mcp_bridge/tools/search_enhancements.py +347 -0
- mcp_bridge/tools/semantic_search.py +3627 -0
- mcp_bridge/tools/session_manager.py +0 -2
- mcp_bridge/tools/skill_loader.py +0 -1
- mcp_bridge/tools/task_runner.py +5 -7
- mcp_bridge/tools/templates.py +3 -3
- mcp_bridge/tools/tool_search.py +331 -0
- mcp_bridge/tools/write_file.py +29 -0
- mcp_bridge/update_manager.py +585 -0
- mcp_bridge/update_manager_pypi.py +297 -0
- mcp_bridge/utils/cache.py +82 -0
- mcp_bridge/utils/process.py +71 -0
- mcp_bridge/utils/session_state.py +51 -0
- mcp_bridge/utils/truncation.py +76 -0
- stravinsky-0.4.66.dist-info/METADATA +517 -0
- stravinsky-0.4.66.dist-info/RECORD +198 -0
- {stravinsky-0.2.67.dist-info → stravinsky-0.4.66.dist-info}/entry_points.txt +1 -0
- stravinsky_claude_assets/HOOKS_INTEGRATION.md +316 -0
- stravinsky_claude_assets/agents/HOOKS.md +437 -0
- stravinsky_claude_assets/agents/code-reviewer.md +210 -0
- stravinsky_claude_assets/agents/comment_checker.md +580 -0
- stravinsky_claude_assets/agents/debugger.md +254 -0
- stravinsky_claude_assets/agents/delphi.md +495 -0
- stravinsky_claude_assets/agents/dewey.md +248 -0
- stravinsky_claude_assets/agents/explore.md +1198 -0
- stravinsky_claude_assets/agents/frontend.md +472 -0
- stravinsky_claude_assets/agents/implementation-lead.md +164 -0
- stravinsky_claude_assets/agents/momus.md +464 -0
- stravinsky_claude_assets/agents/research-lead.md +141 -0
- stravinsky_claude_assets/agents/stravinsky.md +730 -0
- stravinsky_claude_assets/commands/delphi.md +9 -0
- stravinsky_claude_assets/commands/dewey.md +54 -0
- stravinsky_claude_assets/commands/git-master.md +112 -0
- stravinsky_claude_assets/commands/index.md +49 -0
- stravinsky_claude_assets/commands/publish.md +86 -0
- stravinsky_claude_assets/commands/review.md +73 -0
- stravinsky_claude_assets/commands/str/agent_cancel.md +70 -0
- stravinsky_claude_assets/commands/str/agent_list.md +56 -0
- stravinsky_claude_assets/commands/str/agent_output.md +92 -0
- stravinsky_claude_assets/commands/str/agent_progress.md +74 -0
- stravinsky_claude_assets/commands/str/agent_retry.md +94 -0
- stravinsky_claude_assets/commands/str/cancel.md +51 -0
- stravinsky_claude_assets/commands/str/clean.md +97 -0
- stravinsky_claude_assets/commands/str/continue.md +38 -0
- stravinsky_claude_assets/commands/str/index.md +199 -0
- stravinsky_claude_assets/commands/str/list_watchers.md +96 -0
- stravinsky_claude_assets/commands/str/search.md +205 -0
- stravinsky_claude_assets/commands/str/start_filewatch.md +136 -0
- stravinsky_claude_assets/commands/str/stats.md +71 -0
- stravinsky_claude_assets/commands/str/stop_filewatch.md +89 -0
- stravinsky_claude_assets/commands/str/unwatch.md +42 -0
- stravinsky_claude_assets/commands/str/watch.md +45 -0
- stravinsky_claude_assets/commands/strav.md +53 -0
- stravinsky_claude_assets/commands/stravinsky.md +292 -0
- stravinsky_claude_assets/commands/verify.md +60 -0
- stravinsky_claude_assets/commands/version.md +5 -0
- stravinsky_claude_assets/hooks/README.md +248 -0
- stravinsky_claude_assets/hooks/comment_checker.py +193 -0
- stravinsky_claude_assets/hooks/context.py +38 -0
- stravinsky_claude_assets/hooks/context_monitor.py +153 -0
- stravinsky_claude_assets/hooks/dependency_tracker.py +73 -0
- stravinsky_claude_assets/hooks/edit_recovery.py +46 -0
- stravinsky_claude_assets/hooks/execution_state_tracker.py +68 -0
- stravinsky_claude_assets/hooks/notification_hook.py +103 -0
- stravinsky_claude_assets/hooks/notification_hook_v2.py +96 -0
- stravinsky_claude_assets/hooks/parallel_execution.py +241 -0
- stravinsky_claude_assets/hooks/parallel_reinforcement.py +106 -0
- stravinsky_claude_assets/hooks/parallel_reinforcement_v2.py +112 -0
- stravinsky_claude_assets/hooks/pre_compact.py +123 -0
- stravinsky_claude_assets/hooks/ralph_loop.py +173 -0
- stravinsky_claude_assets/hooks/session_recovery.py +263 -0
- stravinsky_claude_assets/hooks/stop_hook.py +89 -0
- stravinsky_claude_assets/hooks/stravinsky_metrics.py +164 -0
- stravinsky_claude_assets/hooks/stravinsky_mode.py +146 -0
- stravinsky_claude_assets/hooks/subagent_stop.py +98 -0
- stravinsky_claude_assets/hooks/todo_continuation.py +111 -0
- stravinsky_claude_assets/hooks/todo_delegation.py +96 -0
- stravinsky_claude_assets/hooks/tool_messaging.py +281 -0
- stravinsky_claude_assets/hooks/truncator.py +23 -0
- stravinsky_claude_assets/rules/deployment_safety.md +51 -0
- stravinsky_claude_assets/rules/integration_wiring.md +89 -0
- stravinsky_claude_assets/rules/pypi_deployment.md +220 -0
- stravinsky_claude_assets/rules/stravinsky_orchestrator.md +32 -0
- stravinsky_claude_assets/settings.json +152 -0
- stravinsky_claude_assets/skills/chrome-devtools/SKILL.md +81 -0
- stravinsky_claude_assets/skills/sqlite/SKILL.md +77 -0
- stravinsky_claude_assets/skills/supabase/SKILL.md +74 -0
- stravinsky_claude_assets/task_dependencies.json +34 -0
- stravinsky-0.2.67.dist-info/METADATA +0 -284
- stravinsky-0.2.67.dist-info/RECORD +0 -76
- {stravinsky-0.2.67.dist-info → stravinsky-0.4.66.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from mcp_bridge.utils.cache import IOCache
|
|
4
|
+
|
|
5
|
+
async def list_directory(path: str) -> str:
|
|
6
|
+
"""
|
|
7
|
+
List files and directories in a path with caching.
|
|
8
|
+
"""
|
|
9
|
+
# USER-VISIBLE NOTIFICATION
|
|
10
|
+
import sys
|
|
11
|
+
print(f"📂 LIST: {path}", file=sys.stderr)
|
|
12
|
+
|
|
13
|
+
cache = IOCache.get_instance()
|
|
14
|
+
cache_key = f"list_dir:{os.path.realpath(path)}"
|
|
15
|
+
|
|
16
|
+
cached_result = cache.get(cache_key)
|
|
17
|
+
if cached_result:
|
|
18
|
+
return cached_result
|
|
19
|
+
|
|
20
|
+
dir_path = Path(path)
|
|
21
|
+
if not dir_path.exists():
|
|
22
|
+
return f"Error: Directory not found: {path}"
|
|
23
|
+
|
|
24
|
+
if not dir_path.is_dir():
|
|
25
|
+
return f"Error: Path is not a directory: {path}"
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
entries = []
|
|
29
|
+
# Sort for deterministic output
|
|
30
|
+
for entry in sorted(dir_path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
|
|
31
|
+
entry_type = "DIR" if entry.is_dir() else "FILE"
|
|
32
|
+
entries.append(f"[{entry_type}] {entry.name}")
|
|
33
|
+
|
|
34
|
+
result = "\n".join(entries) if entries else "(empty directory)"
|
|
35
|
+
|
|
36
|
+
# Cache for 5 seconds
|
|
37
|
+
cache.set(cache_key, result)
|
|
38
|
+
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
except Exception as e:
|
|
42
|
+
return f"Error listing directory {path}: {str(e)}"
|
mcp_bridge/tools/lsp/__init__.py
CHANGED
|
@@ -4,16 +4,19 @@ LSP Tools Package
|
|
|
4
4
|
Provides Language Server Protocol functionality for code intelligence.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
from .manager import LSPManager, get_lsp_manager
|
|
7
8
|
from .tools import (
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
lsp_find_references,
|
|
9
|
+
lsp_code_action_resolve,
|
|
10
|
+
lsp_code_actions,
|
|
11
11
|
lsp_document_symbols,
|
|
12
|
-
|
|
12
|
+
lsp_extract_refactor,
|
|
13
|
+
lsp_find_references,
|
|
14
|
+
lsp_goto_definition,
|
|
15
|
+
lsp_hover,
|
|
13
16
|
lsp_prepare_rename,
|
|
14
17
|
lsp_rename,
|
|
15
|
-
lsp_code_actions,
|
|
16
18
|
lsp_servers,
|
|
19
|
+
lsp_workspace_symbols,
|
|
17
20
|
)
|
|
18
21
|
|
|
19
22
|
__all__ = [
|
|
@@ -25,5 +28,9 @@ __all__ = [
|
|
|
25
28
|
"lsp_prepare_rename",
|
|
26
29
|
"lsp_rename",
|
|
27
30
|
"lsp_code_actions",
|
|
31
|
+
"lsp_code_action_resolve",
|
|
32
|
+
"lsp_extract_refactor",
|
|
28
33
|
"lsp_servers",
|
|
34
|
+
"LSPManager",
|
|
35
|
+
"get_lsp_manager",
|
|
29
36
|
]
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Persistent LSP Server Manager
|
|
3
|
+
|
|
4
|
+
Manages persistent Language Server Protocol (LSP) servers for improved performance.
|
|
5
|
+
Implements lazy initialization, JSON-RPC communication, and graceful shutdown.
|
|
6
|
+
|
|
7
|
+
Architecture:
|
|
8
|
+
- Servers start on first use (lazy initialization)
|
|
9
|
+
- JSON-RPC over stdio using pygls BaseLanguageClient
|
|
10
|
+
- Supports Python (jedi-language-server) and TypeScript (typescript-language-server)
|
|
11
|
+
- Graceful shutdown on MCP server exit
|
|
12
|
+
- Health checks and idle timeout management
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from lsprotocol.types import (
|
|
24
|
+
ClientCapabilities,
|
|
25
|
+
InitializedParams,
|
|
26
|
+
InitializeParams,
|
|
27
|
+
)
|
|
28
|
+
from pygls.client import JsonRPCClient
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# Configuration for LSP server lifecycle management
|
|
33
|
+
LSP_CONFIG = {
|
|
34
|
+
"idle_timeout": 1800, # 30 minutes
|
|
35
|
+
"health_check_interval": 300, # 5 minutes
|
|
36
|
+
"health_check_timeout": 5.0,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class LSPServer:
|
|
42
|
+
"""Metadata for a persistent LSP server."""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
command: list[str]
|
|
46
|
+
client: JsonRPCClient | None = None
|
|
47
|
+
initialized: bool = False
|
|
48
|
+
process: asyncio.subprocess.Process | None = None
|
|
49
|
+
pid: int | None = None # Track subprocess PID for explicit cleanup
|
|
50
|
+
root_path: str | None = None # Track root path server was started with
|
|
51
|
+
last_used: float = field(default_factory=time.time) # Timestamp of last usage
|
|
52
|
+
created_at: float = field(default_factory=time.time) # Timestamp of server creation
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class LSPManager:
|
|
56
|
+
"""
|
|
57
|
+
Singleton manager for persistent LSP servers.
|
|
58
|
+
|
|
59
|
+
Implements:
|
|
60
|
+
- Lazy server initialization (start on first use)
|
|
61
|
+
- Process lifecycle management with GC protection
|
|
62
|
+
- Exponential backoff for crash recovery
|
|
63
|
+
- Graceful shutdown with signal handling
|
|
64
|
+
- Health checks and idle server shutdown
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
_instance: Optional["LSPManager"] = None
|
|
68
|
+
|
|
69
|
+
def __new__(cls):
|
|
70
|
+
if cls._instance is None:
|
|
71
|
+
cls._instance = super().__new__(cls)
|
|
72
|
+
return cls._instance
|
|
73
|
+
|
|
74
|
+
def __init__(self):
|
|
75
|
+
if hasattr(self, "_initialized"):
|
|
76
|
+
return
|
|
77
|
+
self._initialized = True
|
|
78
|
+
self._servers: dict[str, LSPServer] = {}
|
|
79
|
+
self._lock = asyncio.Lock()
|
|
80
|
+
self._restart_attempts: dict[str, int] = {}
|
|
81
|
+
self._health_monitor_task: asyncio.Task | None = None
|
|
82
|
+
|
|
83
|
+
# Register available LSP servers
|
|
84
|
+
self._register_servers()
|
|
85
|
+
|
|
86
|
+
def _register_servers(self):
|
|
87
|
+
"""Register available LSP server configurations."""
|
|
88
|
+
# Allow overriding commands via environment variables
|
|
89
|
+
python_cmd = os.environ.get("LSP_CMD_PYTHON", "jedi-language-server").split()
|
|
90
|
+
ts_cmd = os.environ.get(
|
|
91
|
+
"LSP_CMD_TYPESCRIPT", "typescript-language-server --stdio"
|
|
92
|
+
).split()
|
|
93
|
+
|
|
94
|
+
self._servers["python"] = LSPServer(name="python", command=python_cmd)
|
|
95
|
+
self._servers["typescript"] = LSPServer(name="typescript", command=ts_cmd)
|
|
96
|
+
|
|
97
|
+
async def get_server(
|
|
98
|
+
self, language: str, root_path: str | None = None
|
|
99
|
+
) -> JsonRPCClient | None:
|
|
100
|
+
"""
|
|
101
|
+
Get or start a persistent LSP server for the given language.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
language: Language identifier (e.g., "python", "typescript")
|
|
105
|
+
root_path: Project root path (optional, but recommended)
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
JsonRPCClient instance or None if server unavailable
|
|
109
|
+
"""
|
|
110
|
+
if language not in self._servers:
|
|
111
|
+
logger.warning(f"No LSP server configured for language: {language}")
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
server = self._servers[language]
|
|
115
|
+
|
|
116
|
+
# Check if we need to restart due to root path change
|
|
117
|
+
# (Simple implementation: if root_path differs, restart)
|
|
118
|
+
# In multi-root workspaces, this might be too aggressive, but safe for now.
|
|
119
|
+
restart_needed = False
|
|
120
|
+
if root_path and server.root_path and root_path != server.root_path:
|
|
121
|
+
logger.info(
|
|
122
|
+
f"Restarting {language} LSP server: root path changed ({server.root_path} -> {root_path})"
|
|
123
|
+
)
|
|
124
|
+
restart_needed = True
|
|
125
|
+
|
|
126
|
+
if restart_needed:
|
|
127
|
+
async with self._lock:
|
|
128
|
+
await self._shutdown_single_server(language, server)
|
|
129
|
+
|
|
130
|
+
# Return existing initialized server
|
|
131
|
+
if server.initialized and server.client:
|
|
132
|
+
# Update last_used timestamp
|
|
133
|
+
server.last_used = time.time()
|
|
134
|
+
# Start health monitor on first use
|
|
135
|
+
if self._health_monitor_task is None or self._health_monitor_task.done():
|
|
136
|
+
self._health_monitor_task = asyncio.create_task(self._background_health_monitor())
|
|
137
|
+
return server.client
|
|
138
|
+
|
|
139
|
+
# Start server with lock to prevent race conditions
|
|
140
|
+
async with self._lock:
|
|
141
|
+
# Double-check after acquiring lock
|
|
142
|
+
if server.initialized and server.client:
|
|
143
|
+
server.last_used = time.time()
|
|
144
|
+
if self._health_monitor_task is None or self._health_monitor_task.done():
|
|
145
|
+
self._health_monitor_task = asyncio.create_task(self._background_health_monitor())
|
|
146
|
+
return server.client
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
await self._start_server(server, root_path)
|
|
150
|
+
# Start health monitor on first server creation
|
|
151
|
+
if self._health_monitor_task is None or self._health_monitor_task.done():
|
|
152
|
+
self._health_monitor_task = asyncio.create_task(self._background_health_monitor())
|
|
153
|
+
return server.client
|
|
154
|
+
except Exception as e:
|
|
155
|
+
logger.error(f"Failed to start {language} LSP server: {e}")
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
async def _start_server(self, server: LSPServer, root_path: str | None = None):
|
|
159
|
+
"""
|
|
160
|
+
Start a persistent LSP server process.
|
|
161
|
+
|
|
162
|
+
Implements:
|
|
163
|
+
- Process health validation after start
|
|
164
|
+
- LSP initialization handshake
|
|
165
|
+
- GC protection via persistent reference
|
|
166
|
+
- Timestamp tracking for idle detection
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
server: LSPServer metadata object
|
|
170
|
+
root_path: Project root path
|
|
171
|
+
"""
|
|
172
|
+
try:
|
|
173
|
+
# Create pygls client
|
|
174
|
+
client = JsonRPCClient()
|
|
175
|
+
|
|
176
|
+
logger.info(f"Starting {server.name} LSP server: {' '.join(server.command)}")
|
|
177
|
+
|
|
178
|
+
# Start server process (start_io expects cmd as first arg, then *args)
|
|
179
|
+
# Use cwd=root_path if available to help server find config
|
|
180
|
+
cwd = root_path if root_path and os.path.isdir(root_path) else None
|
|
181
|
+
await client.start_io(server.command[0], *server.command[1:], cwd=cwd)
|
|
182
|
+
|
|
183
|
+
# Brief delay for process startup
|
|
184
|
+
await asyncio.sleep(0.2)
|
|
185
|
+
|
|
186
|
+
# Capture subprocess from client
|
|
187
|
+
if not hasattr(client, "_server") or client._server is None:
|
|
188
|
+
raise ConnectionError(
|
|
189
|
+
f"{server.name} LSP server process not accessible after start_io()"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
server.process = client._server
|
|
193
|
+
server.pid = server.process.pid
|
|
194
|
+
logger.debug(f"{server.name} LSP server started with PID: {server.pid}")
|
|
195
|
+
|
|
196
|
+
# Validate process is still running
|
|
197
|
+
if server.process.returncode is not None:
|
|
198
|
+
raise ConnectionError(
|
|
199
|
+
f"{server.name} LSP server exited immediately (code {server.process.returncode})"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Perform LSP initialization handshake
|
|
203
|
+
root_uri = f"file://{root_path}" if root_path else None
|
|
204
|
+
init_params = InitializeParams(
|
|
205
|
+
process_id=None, root_uri=root_uri, capabilities=ClientCapabilities()
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
# Send initialize request via protocol
|
|
210
|
+
response = await asyncio.wait_for(
|
|
211
|
+
client.protocol.send_request_async("initialize", init_params), timeout=10.0
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Send initialized notification
|
|
215
|
+
client.protocol.notify("initialized", InitializedParams())
|
|
216
|
+
|
|
217
|
+
logger.info(f"{server.name} LSP server initialized: {response}")
|
|
218
|
+
|
|
219
|
+
except TimeoutError:
|
|
220
|
+
raise ConnectionError(f"{server.name} LSP server initialization timed out")
|
|
221
|
+
|
|
222
|
+
# Store client reference (GC protection)
|
|
223
|
+
server.client = client
|
|
224
|
+
server.initialized = True
|
|
225
|
+
server.root_path = root_path
|
|
226
|
+
server.created_at = time.time()
|
|
227
|
+
server.last_used = time.time()
|
|
228
|
+
|
|
229
|
+
# Reset restart attempts on successful start
|
|
230
|
+
self._restart_attempts[server.name] = 0
|
|
231
|
+
|
|
232
|
+
logger.info(f"{server.name} LSP server started successfully")
|
|
233
|
+
|
|
234
|
+
except Exception as e:
|
|
235
|
+
logger.error(f"Failed to start {server.name} LSP server: {e}", exc_info=True)
|
|
236
|
+
# Cleanup on failure
|
|
237
|
+
if server.client:
|
|
238
|
+
try:
|
|
239
|
+
await server.client.stop()
|
|
240
|
+
except:
|
|
241
|
+
pass
|
|
242
|
+
server.client = None
|
|
243
|
+
server.initialized = False
|
|
244
|
+
server.process = None
|
|
245
|
+
server.pid = None
|
|
246
|
+
server.root_path = None
|
|
247
|
+
raise
|
|
248
|
+
|
|
249
|
+
async def _restart_with_backoff(self, server: LSPServer):
|
|
250
|
+
"""
|
|
251
|
+
Restart a crashed LSP server with exponential backoff.
|
|
252
|
+
|
|
253
|
+
Strategy: delay = 2^attempt + jitter (max 60s)
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
server: LSPServer to restart
|
|
257
|
+
"""
|
|
258
|
+
import random
|
|
259
|
+
|
|
260
|
+
attempt = self._restart_attempts.get(server.name, 0)
|
|
261
|
+
self._restart_attempts[server.name] = attempt + 1
|
|
262
|
+
|
|
263
|
+
# Exponential backoff with jitter (max 60s)
|
|
264
|
+
delay = min(60, (2**attempt) + random.uniform(0, 1))
|
|
265
|
+
|
|
266
|
+
logger.warning(
|
|
267
|
+
f"{server.name} LSP server crashed. Restarting in {delay:.2f}s (attempt {attempt + 1})"
|
|
268
|
+
)
|
|
269
|
+
await asyncio.sleep(delay)
|
|
270
|
+
|
|
271
|
+
# Reset state before restart
|
|
272
|
+
server.initialized = False
|
|
273
|
+
server.client = None
|
|
274
|
+
server.process = None
|
|
275
|
+
server.pid = None
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
await self._start_server(server)
|
|
279
|
+
except Exception as e:
|
|
280
|
+
logger.error(f"Restart failed for {server.name}: {e}")
|
|
281
|
+
|
|
282
|
+
async def _health_check_server(self, server: LSPServer) -> bool:
|
|
283
|
+
"""
|
|
284
|
+
Perform health check on an LSP server.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
server: LSPServer to check
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
True if server is healthy, False otherwise
|
|
291
|
+
"""
|
|
292
|
+
if not server.initialized or not server.client:
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
# Simple health check: send initialize request
|
|
297
|
+
# Most servers respond to repeated initialize calls
|
|
298
|
+
init_params = InitializeParams(
|
|
299
|
+
process_id=None, root_uri=None, capabilities=ClientCapabilities()
|
|
300
|
+
)
|
|
301
|
+
response = await asyncio.wait_for(
|
|
302
|
+
server.client.protocol.send_request_async("initialize", init_params),
|
|
303
|
+
timeout=LSP_CONFIG["health_check_timeout"],
|
|
304
|
+
)
|
|
305
|
+
logger.debug(f"{server.name} LSP server health check passed")
|
|
306
|
+
return True
|
|
307
|
+
except TimeoutError:
|
|
308
|
+
logger.warning(f"{server.name} LSP server health check timed out")
|
|
309
|
+
return False
|
|
310
|
+
except Exception as e:
|
|
311
|
+
logger.warning(f"{server.name} LSP server health check failed: {e}")
|
|
312
|
+
return False
|
|
313
|
+
|
|
314
|
+
async def _shutdown_single_server(self, name: str, server: LSPServer):
|
|
315
|
+
"""
|
|
316
|
+
Gracefully shutdown a single LSP server.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
name: Server name (key)
|
|
320
|
+
server: LSPServer instance
|
|
321
|
+
"""
|
|
322
|
+
if not server.initialized or not server.client:
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
logger.info(f"Shutting down {name} LSP server")
|
|
327
|
+
|
|
328
|
+
# LSP protocol shutdown request
|
|
329
|
+
try:
|
|
330
|
+
await asyncio.wait_for(
|
|
331
|
+
server.client.protocol.send_request_async("shutdown", None), timeout=5.0
|
|
332
|
+
)
|
|
333
|
+
except TimeoutError:
|
|
334
|
+
logger.warning(f"{name} LSP server shutdown request timed out")
|
|
335
|
+
|
|
336
|
+
# Send exit notification
|
|
337
|
+
server.client.protocol.notify("exit", None)
|
|
338
|
+
|
|
339
|
+
# Stop the client
|
|
340
|
+
await server.client.stop()
|
|
341
|
+
|
|
342
|
+
# Terminate subprocess using stored process reference
|
|
343
|
+
if server.process is not None:
|
|
344
|
+
try:
|
|
345
|
+
if server.process.returncode is not None:
|
|
346
|
+
logger.debug(f"{name} already exited (code {server.process.returncode})")
|
|
347
|
+
else:
|
|
348
|
+
server.process.terminate()
|
|
349
|
+
try:
|
|
350
|
+
await asyncio.wait_for(server.process.wait(), timeout=2.0)
|
|
351
|
+
except TimeoutError:
|
|
352
|
+
server.process.kill()
|
|
353
|
+
await asyncio.wait_for(server.process.wait(), timeout=1.0)
|
|
354
|
+
except Exception as e:
|
|
355
|
+
logger.warning(f"Error terminating {name}: {e}")
|
|
356
|
+
|
|
357
|
+
# Mark as uninitialized
|
|
358
|
+
server.initialized = False
|
|
359
|
+
server.client = None
|
|
360
|
+
server.process = None
|
|
361
|
+
server.pid = None
|
|
362
|
+
|
|
363
|
+
except Exception as e:
|
|
364
|
+
logger.error(f"Error shutting down {name} LSP server: {e}")
|
|
365
|
+
|
|
366
|
+
async def _background_health_monitor(self):
|
|
367
|
+
"""
|
|
368
|
+
Background task for health checking and idle server shutdown.
|
|
369
|
+
|
|
370
|
+
Runs periodically to:
|
|
371
|
+
- Check health of running servers
|
|
372
|
+
- Shutdown idle servers
|
|
373
|
+
- Restart crashed servers
|
|
374
|
+
"""
|
|
375
|
+
logger.info("LSP health monitor task started")
|
|
376
|
+
try:
|
|
377
|
+
while True:
|
|
378
|
+
await asyncio.sleep(LSP_CONFIG["health_check_interval"])
|
|
379
|
+
|
|
380
|
+
current_time = time.time()
|
|
381
|
+
idle_threshold = current_time - LSP_CONFIG["idle_timeout"]
|
|
382
|
+
|
|
383
|
+
async with self._lock:
|
|
384
|
+
for name, server in self._servers.items():
|
|
385
|
+
if not server.initialized or not server.client:
|
|
386
|
+
continue
|
|
387
|
+
|
|
388
|
+
# Check if server is idle
|
|
389
|
+
if server.last_used < idle_threshold:
|
|
390
|
+
logger.info(
|
|
391
|
+
f"{name} LSP server idle for {(current_time - server.last_used) / 60:.1f} minutes, shutting down"
|
|
392
|
+
)
|
|
393
|
+
await self._shutdown_single_server(name, server)
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
# Health check for active servers
|
|
397
|
+
is_healthy = await self._health_check_server(server)
|
|
398
|
+
if not is_healthy:
|
|
399
|
+
logger.warning(f"{name} LSP server health check failed, restarting")
|
|
400
|
+
await self._shutdown_single_server(name, server)
|
|
401
|
+
try:
|
|
402
|
+
await self._start_server(server)
|
|
403
|
+
except Exception as e:
|
|
404
|
+
logger.error(f"Failed to restart {name} LSP server: {e}")
|
|
405
|
+
|
|
406
|
+
except asyncio.CancelledError:
|
|
407
|
+
logger.info("LSP health monitor task cancelled")
|
|
408
|
+
raise
|
|
409
|
+
except Exception as e:
|
|
410
|
+
logger.error(f"LSP health monitor task error: {e}", exc_info=True)
|
|
411
|
+
|
|
412
|
+
def get_status(self) -> dict:
|
|
413
|
+
"""Get status of managed servers including idle information."""
|
|
414
|
+
current_time = time.time()
|
|
415
|
+
status = {}
|
|
416
|
+
for name, server in self._servers.items():
|
|
417
|
+
idle_seconds = current_time - server.last_used
|
|
418
|
+
uptime_seconds = current_time - server.created_at if server.created_at else 0
|
|
419
|
+
status[name] = {
|
|
420
|
+
"running": server.initialized and server.client is not None,
|
|
421
|
+
"pid": server.pid,
|
|
422
|
+
"command": " ".join(server.command),
|
|
423
|
+
"restarts": self._restart_attempts.get(name, 0),
|
|
424
|
+
"idle_seconds": idle_seconds,
|
|
425
|
+
"idle_minutes": idle_seconds / 60.0,
|
|
426
|
+
"uptime_seconds": uptime_seconds,
|
|
427
|
+
}
|
|
428
|
+
return status
|
|
429
|
+
|
|
430
|
+
async def shutdown(self):
|
|
431
|
+
"""
|
|
432
|
+
Gracefully shutdown all LSP servers.
|
|
433
|
+
|
|
434
|
+
Implements:
|
|
435
|
+
- Health monitor cancellation
|
|
436
|
+
- LSP protocol shutdown (shutdown request + exit notification)
|
|
437
|
+
- Pending task cancellation
|
|
438
|
+
- Process cleanup with timeout
|
|
439
|
+
"""
|
|
440
|
+
logger.info("Shutting down LSP manager...")
|
|
441
|
+
|
|
442
|
+
# Cancel health monitor task
|
|
443
|
+
if self._health_monitor_task and not self._health_monitor_task.done():
|
|
444
|
+
logger.info("Cancelling health monitor task")
|
|
445
|
+
self._health_monitor_task.cancel()
|
|
446
|
+
try:
|
|
447
|
+
await self._health_monitor_task
|
|
448
|
+
except asyncio.CancelledError:
|
|
449
|
+
pass
|
|
450
|
+
|
|
451
|
+
async with self._lock:
|
|
452
|
+
for name, server in self._servers.items():
|
|
453
|
+
await self._shutdown_single_server(name, server)
|
|
454
|
+
|
|
455
|
+
logger.info("LSP manager shutdown complete")
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# Singleton accessor
|
|
459
|
+
_manager_instance: LSPManager | None = None
|
|
460
|
+
_manager_lock = threading.Lock()
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def get_lsp_manager() -> LSPManager:
|
|
464
|
+
"""Get the global LSP manager singleton."""
|
|
465
|
+
global _manager_instance
|
|
466
|
+
if _manager_instance is None:
|
|
467
|
+
with _manager_lock:
|
|
468
|
+
# Double-check pattern to avoid race condition
|
|
469
|
+
if _manager_instance is None:
|
|
470
|
+
_manager_instance = LSPManager()
|
|
471
|
+
return _manager_instance
|