mcp-mesh 0.5.4__py3-none-any.whl → 0.5.6__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.
- _mcp_mesh/__init__.py +5 -2
- _mcp_mesh/engine/decorator_registry.py +95 -0
- _mcp_mesh/engine/mcp_client_proxy.py +17 -7
- _mcp_mesh/engine/unified_mcp_proxy.py +43 -40
- _mcp_mesh/pipeline/api_startup/fastapi_discovery.py +4 -167
- _mcp_mesh/pipeline/mcp_heartbeat/heartbeat_orchestrator.py +4 -0
- _mcp_mesh/pipeline/mcp_heartbeat/lifespan_integration.py +13 -0
- _mcp_mesh/pipeline/mcp_startup/__init__.py +2 -0
- _mcp_mesh/pipeline/mcp_startup/fastapiserver_setup.py +306 -163
- _mcp_mesh/pipeline/mcp_startup/server_discovery.py +164 -0
- _mcp_mesh/pipeline/mcp_startup/startup_orchestrator.py +198 -160
- _mcp_mesh/pipeline/mcp_startup/startup_pipeline.py +7 -4
- _mcp_mesh/pipeline/shared/mesh_pipeline.py +4 -0
- _mcp_mesh/shared/server_discovery.py +312 -0
- _mcp_mesh/shared/simple_shutdown.py +217 -0
- {mcp_mesh-0.5.4.dist-info → mcp_mesh-0.5.6.dist-info}/METADATA +1 -1
- {mcp_mesh-0.5.4.dist-info → mcp_mesh-0.5.6.dist-info}/RECORD +20 -18
- mesh/decorators.py +303 -36
- _mcp_mesh/engine/threading_utils.py +0 -223
- {mcp_mesh-0.5.4.dist-info → mcp_mesh-0.5.6.dist-info}/WHEEL +0 -0
- {mcp_mesh-0.5.4.dist-info → mcp_mesh-0.5.6.dist-info}/licenses/LICENSE +0 -0
_mcp_mesh/__init__.py
CHANGED
|
@@ -31,7 +31,7 @@ from .engine.decorator_registry import (
|
|
|
31
31
|
get_decorator_stats,
|
|
32
32
|
)
|
|
33
33
|
|
|
34
|
-
__version__ = "0.5.
|
|
34
|
+
__version__ = "0.5.6"
|
|
35
35
|
|
|
36
36
|
# Store reference to runtime processor if initialized
|
|
37
37
|
_runtime_processor = None
|
|
@@ -59,7 +59,10 @@ def initialize_runtime():
|
|
|
59
59
|
|
|
60
60
|
|
|
61
61
|
# Auto-initialize runtime if enabled
|
|
62
|
-
if
|
|
62
|
+
if (
|
|
63
|
+
os.getenv("MCP_MESH_ENABLED", "true").lower() == "true"
|
|
64
|
+
and os.getenv("MCP_MESH_AUTO_RUN", "true").lower() == "true"
|
|
65
|
+
):
|
|
63
66
|
# Use debounced initialization instead of immediate MCP startup
|
|
64
67
|
# This allows the system to determine MCP vs API pipeline based on decorators
|
|
65
68
|
try:
|
|
@@ -55,6 +55,15 @@ class DecoratorRegistry:
|
|
|
55
55
|
|
|
56
56
|
# Registry for new decorator types (extensibility)
|
|
57
57
|
_custom_decorators: dict[str, dict[str, DecoratedFunction]] = {}
|
|
58
|
+
|
|
59
|
+
# Immediate uvicorn server storage (for preventing shutdown state)
|
|
60
|
+
_immediate_uvicorn_server: Optional[dict[str, Any]] = None
|
|
61
|
+
|
|
62
|
+
# FastMCP lifespan storage (for proper integration with FastAPI)
|
|
63
|
+
_fastmcp_lifespan: Optional[Any] = None
|
|
64
|
+
|
|
65
|
+
# FastMCP HTTP app storage (the same app instance whose lifespan was extracted)
|
|
66
|
+
_fastmcp_http_app: Optional[Any] = None
|
|
58
67
|
|
|
59
68
|
@classmethod
|
|
60
69
|
def register_mesh_agent(cls, func: Callable, metadata: dict[str, Any]) -> None:
|
|
@@ -508,6 +517,92 @@ class DecoratorRegistry:
|
|
|
508
517
|
|
|
509
518
|
return {}
|
|
510
519
|
|
|
520
|
+
@classmethod
|
|
521
|
+
def store_immediate_uvicorn_server(cls, server_info: dict[str, Any]) -> None:
|
|
522
|
+
"""
|
|
523
|
+
Store reference to immediate uvicorn server started in decorator.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
server_info: Dictionary containing server information:
|
|
527
|
+
- 'app': FastAPI app instance
|
|
528
|
+
- 'host': Server host
|
|
529
|
+
- 'port': Server port
|
|
530
|
+
- 'thread': Thread object
|
|
531
|
+
- Any other relevant server metadata
|
|
532
|
+
"""
|
|
533
|
+
cls._immediate_uvicorn_server = server_info
|
|
534
|
+
logger.debug(f"🔄 REGISTRY: Stored immediate uvicorn server reference: {server_info.get('host')}:{server_info.get('port')}")
|
|
535
|
+
|
|
536
|
+
@classmethod
|
|
537
|
+
def get_immediate_uvicorn_server(cls) -> Optional[dict[str, Any]]:
|
|
538
|
+
"""
|
|
539
|
+
Get stored immediate uvicorn server reference.
|
|
540
|
+
|
|
541
|
+
Returns:
|
|
542
|
+
Server info dict if available, None otherwise
|
|
543
|
+
"""
|
|
544
|
+
return cls._immediate_uvicorn_server
|
|
545
|
+
|
|
546
|
+
@classmethod
|
|
547
|
+
def clear_immediate_uvicorn_server(cls) -> None:
|
|
548
|
+
"""Clear stored immediate uvicorn server reference."""
|
|
549
|
+
cls._immediate_uvicorn_server = None
|
|
550
|
+
logger.debug("🔄 REGISTRY: Cleared immediate uvicorn server reference")
|
|
551
|
+
|
|
552
|
+
@classmethod
|
|
553
|
+
def store_fastmcp_lifespan(cls, lifespan: Any) -> None:
|
|
554
|
+
"""
|
|
555
|
+
Store FastMCP lifespan for integration with FastAPI.
|
|
556
|
+
|
|
557
|
+
Args:
|
|
558
|
+
lifespan: FastMCP lifespan function
|
|
559
|
+
"""
|
|
560
|
+
cls._fastmcp_lifespan = lifespan
|
|
561
|
+
logger.debug("🔄 REGISTRY: Stored FastMCP lifespan for FastAPI integration")
|
|
562
|
+
|
|
563
|
+
@classmethod
|
|
564
|
+
def get_fastmcp_lifespan(cls) -> Optional[Any]:
|
|
565
|
+
"""
|
|
566
|
+
Get stored FastMCP lifespan.
|
|
567
|
+
|
|
568
|
+
Returns:
|
|
569
|
+
FastMCP lifespan if available, None otherwise
|
|
570
|
+
"""
|
|
571
|
+
return cls._fastmcp_lifespan
|
|
572
|
+
|
|
573
|
+
@classmethod
|
|
574
|
+
def clear_fastmcp_lifespan(cls) -> None:
|
|
575
|
+
"""Clear stored FastMCP lifespan reference."""
|
|
576
|
+
cls._fastmcp_lifespan = None
|
|
577
|
+
logger.debug("🔄 REGISTRY: Cleared FastMCP lifespan reference")
|
|
578
|
+
|
|
579
|
+
@classmethod
|
|
580
|
+
def store_fastmcp_http_app(cls, http_app: Any) -> None:
|
|
581
|
+
"""
|
|
582
|
+
Store FastMCP HTTP app (the same instance whose lifespan was extracted).
|
|
583
|
+
|
|
584
|
+
Args:
|
|
585
|
+
http_app: FastMCP HTTP app instance
|
|
586
|
+
"""
|
|
587
|
+
cls._fastmcp_http_app = http_app
|
|
588
|
+
logger.debug("🔄 REGISTRY: Stored FastMCP HTTP app for mounting")
|
|
589
|
+
|
|
590
|
+
@classmethod
|
|
591
|
+
def get_fastmcp_http_app(cls) -> Optional[Any]:
|
|
592
|
+
"""
|
|
593
|
+
Get stored FastMCP HTTP app.
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
FastMCP HTTP app if available, None otherwise
|
|
597
|
+
"""
|
|
598
|
+
return cls._fastmcp_http_app
|
|
599
|
+
|
|
600
|
+
@classmethod
|
|
601
|
+
def clear_fastmcp_http_app(cls) -> None:
|
|
602
|
+
"""Clear stored FastMCP HTTP app reference."""
|
|
603
|
+
cls._fastmcp_http_app = None
|
|
604
|
+
logger.debug("🔄 REGISTRY: Cleared FastMCP HTTP app reference")
|
|
605
|
+
|
|
511
606
|
|
|
512
607
|
# Convenience functions for external access
|
|
513
608
|
def get_all_mesh_agents() -> dict[str, DecoratedFunction]:
|
|
@@ -12,7 +12,6 @@ from typing import Any, Optional
|
|
|
12
12
|
from ..shared.content_extractor import ContentExtractor
|
|
13
13
|
from ..shared.sse_parser import SSEParser
|
|
14
14
|
from .async_mcp_client import AsyncMCPClient
|
|
15
|
-
from .threading_utils import ThreadingUtils
|
|
16
15
|
|
|
17
16
|
logger = logging.getLogger(__name__)
|
|
18
17
|
|
|
@@ -48,13 +47,24 @@ class MCPClientProxy:
|
|
|
48
47
|
)
|
|
49
48
|
|
|
50
49
|
def _run_async(self, coro):
|
|
51
|
-
"""Convert async coroutine to sync call
|
|
50
|
+
"""Convert async coroutine to sync call."""
|
|
52
51
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
try:
|
|
53
|
+
# Try to get existing event loop
|
|
54
|
+
loop = asyncio.get_event_loop()
|
|
55
|
+
if loop.is_running():
|
|
56
|
+
# We're in an async context, need to run in thread
|
|
57
|
+
import concurrent.futures
|
|
58
|
+
|
|
59
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
60
|
+
future = executor.submit(asyncio.run, coro)
|
|
61
|
+
return future.result()
|
|
62
|
+
else:
|
|
63
|
+
# No running loop, safe to use loop.run_until_complete
|
|
64
|
+
return loop.run_until_complete(coro)
|
|
65
|
+
except RuntimeError:
|
|
66
|
+
# No event loop exists, create new one
|
|
67
|
+
return asyncio.run(coro)
|
|
58
68
|
|
|
59
69
|
def __call__(self, **kwargs) -> Any:
|
|
60
70
|
"""Callable interface for dependency injection.
|
|
@@ -10,8 +10,6 @@ import uuid
|
|
|
10
10
|
from collections.abc import AsyncIterator
|
|
11
11
|
from typing import Any, Optional
|
|
12
12
|
|
|
13
|
-
from .threading_utils import ThreadingUtils
|
|
14
|
-
|
|
15
13
|
logger = logging.getLogger(__name__)
|
|
16
14
|
|
|
17
15
|
|
|
@@ -58,11 +56,27 @@ class UnifiedMCPProxy:
|
|
|
58
56
|
f"🔧 UnifiedMCPProxy initialized with kwargs: {self.kwargs_config}"
|
|
59
57
|
)
|
|
60
58
|
|
|
59
|
+
def _is_ip_address(self, hostname: str) -> bool:
|
|
60
|
+
"""Check if hostname is an IP address vs DNS name.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
hostname: Hostname to check
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
True if IP address, False if DNS name
|
|
67
|
+
"""
|
|
68
|
+
import ipaddress
|
|
69
|
+
try:
|
|
70
|
+
ipaddress.ip_address(hostname)
|
|
71
|
+
return True
|
|
72
|
+
except ValueError:
|
|
73
|
+
return False
|
|
74
|
+
|
|
61
75
|
def _create_fastmcp_client(self, endpoint: str):
|
|
62
|
-
"""Create FastMCP client with
|
|
76
|
+
"""Create FastMCP client with DNS detection for threading conflict avoidance.
|
|
63
77
|
|
|
64
|
-
This method
|
|
65
|
-
|
|
78
|
+
This method detects DNS names vs IP addresses and forces HTTP fallback for DNS names
|
|
79
|
+
to avoid FastMCP client threading conflicts in containerized environments.
|
|
66
80
|
|
|
67
81
|
Args:
|
|
68
82
|
endpoint: MCP endpoint URL
|
|
@@ -71,6 +85,14 @@ class UnifiedMCPProxy:
|
|
|
71
85
|
FastMCP Client instance with or without trace headers
|
|
72
86
|
"""
|
|
73
87
|
try:
|
|
88
|
+
# Extract hostname from endpoint URL for DNS detection
|
|
89
|
+
from urllib.parse import urlparse
|
|
90
|
+
parsed = urlparse(endpoint)
|
|
91
|
+
hostname = parsed.hostname or parsed.netloc.split(':')[0]
|
|
92
|
+
|
|
93
|
+
# DNS resolution works perfectly with FastMCP - no need to force HTTP fallback
|
|
94
|
+
self.logger.debug(f"✅ Using FastMCP client for endpoint: {hostname}")
|
|
95
|
+
|
|
74
96
|
from fastmcp import Client
|
|
75
97
|
from fastmcp.client.transports import StreamableHttpTransport
|
|
76
98
|
|
|
@@ -85,16 +107,14 @@ class UnifiedMCPProxy:
|
|
|
85
107
|
# Create standard client when no trace context available
|
|
86
108
|
return Client(endpoint)
|
|
87
109
|
|
|
88
|
-
except ImportError:
|
|
89
|
-
#
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return Client(endpoint)
|
|
110
|
+
except ImportError as e:
|
|
111
|
+
# DNS names or FastMCP not available - this will trigger HTTP fallback
|
|
112
|
+
self.logger.debug(f"🔄 FastMCP client unavailable: {e}")
|
|
113
|
+
raise # Re-raise to trigger _fallback_http_call
|
|
93
114
|
except Exception as e:
|
|
94
|
-
# Any other error
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return Client(endpoint)
|
|
115
|
+
# Any other error - this will trigger HTTP fallback
|
|
116
|
+
self.logger.debug(f"🔄 FastMCP client error: {e}")
|
|
117
|
+
raise ImportError(f"FastMCP client failed: {e}") # Convert to ImportError to trigger fallback
|
|
98
118
|
|
|
99
119
|
def _get_trace_headers(self) -> dict[str, str]:
|
|
100
120
|
"""Extract trace headers from current context for distributed tracing.
|
|
@@ -341,6 +361,7 @@ class UnifiedMCPProxy:
|
|
|
341
361
|
"""
|
|
342
362
|
import time
|
|
343
363
|
|
|
364
|
+
|
|
344
365
|
start_time = time.time()
|
|
345
366
|
|
|
346
367
|
try:
|
|
@@ -350,6 +371,7 @@ class UnifiedMCPProxy:
|
|
|
350
371
|
|
|
351
372
|
# Create client with automatic trace header injection
|
|
352
373
|
client_instance = self._create_fastmcp_client(mcp_endpoint)
|
|
374
|
+
|
|
353
375
|
async with client_instance as client:
|
|
354
376
|
|
|
355
377
|
# Use FastMCP's call_tool which returns CallToolResult object
|
|
@@ -394,7 +416,8 @@ class UnifiedMCPProxy:
|
|
|
394
416
|
self.logger.warning(f"FastMCP Client failed: {e}, falling back to HTTP")
|
|
395
417
|
# Try HTTP fallback
|
|
396
418
|
try:
|
|
397
|
-
|
|
419
|
+
result = await self._fallback_http_call(name, arguments)
|
|
420
|
+
return result
|
|
398
421
|
except Exception as fallback_error:
|
|
399
422
|
raise RuntimeError(
|
|
400
423
|
f"Tool call to '{name}' failed: {e}, fallback also failed: {fallback_error}"
|
|
@@ -831,15 +854,6 @@ class EnhancedUnifiedMCPProxy(UnifiedMCPProxy):
|
|
|
831
854
|
f"auth_required: {self.auth_required}"
|
|
832
855
|
)
|
|
833
856
|
|
|
834
|
-
def __call__(self, **kwargs) -> Any:
|
|
835
|
-
"""Synchronous callable interface for dependency injection.
|
|
836
|
-
|
|
837
|
-
This method provides a sync interface for dependency injection.
|
|
838
|
-
Returns the actual result, not a coroutine.
|
|
839
|
-
"""
|
|
840
|
-
# Use the enhanced tool call with proper sync-to-async bridging
|
|
841
|
-
return self.call_tool_auto(self.function_name, kwargs)
|
|
842
|
-
|
|
843
857
|
async def call_tool_enhanced(self, name: str, arguments: dict = None) -> Any:
|
|
844
858
|
"""Enhanced tool call with retry logic and custom configuration."""
|
|
845
859
|
last_exception = None
|
|
@@ -869,19 +883,8 @@ class EnhancedUnifiedMCPProxy(UnifiedMCPProxy):
|
|
|
869
883
|
raise last_exception
|
|
870
884
|
|
|
871
885
|
def call_tool_auto(self, name: str, arguments: dict = None) -> Any:
|
|
872
|
-
"""Automatically choose streaming vs non-streaming based on configuration.
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
def coro_func():
|
|
878
|
-
if self.streaming_capable:
|
|
879
|
-
return self.call_tool_streaming(name, arguments)
|
|
880
|
-
else:
|
|
881
|
-
return self.call_tool_enhanced(name, arguments)
|
|
882
|
-
|
|
883
|
-
return ThreadingUtils.run_sync_from_async(
|
|
884
|
-
coro_func,
|
|
885
|
-
timeout=self.timeout or 60.0,
|
|
886
|
-
context_name=f"UnifiedMCPProxy.{name}",
|
|
887
|
-
)
|
|
886
|
+
"""Automatically choose streaming vs non-streaming based on configuration."""
|
|
887
|
+
if self.streaming_capable:
|
|
888
|
+
return self.call_tool_streaming(name, arguments)
|
|
889
|
+
else:
|
|
890
|
+
return self.call_tool_enhanced(name, arguments)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import gc
|
|
2
1
|
import logging
|
|
3
|
-
from typing import Any, Dict, List
|
|
2
|
+
from typing import Any, Dict, List
|
|
4
3
|
|
|
5
4
|
from ..shared import PipelineResult, PipelineStatus, PipelineStep
|
|
5
|
+
from ...shared.server_discovery import ServerDiscoveryUtil
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class FastAPIAppDiscoveryStep(PipelineStep):
|
|
@@ -39,8 +39,8 @@ class FastAPIAppDiscoveryStep(PipelineStep):
|
|
|
39
39
|
self.logger.info("⚠️ No @mesh.route decorators found to process")
|
|
40
40
|
return result
|
|
41
41
|
|
|
42
|
-
# Discover FastAPI instances
|
|
43
|
-
fastapi_apps =
|
|
42
|
+
# Discover FastAPI instances using shared utility
|
|
43
|
+
fastapi_apps = ServerDiscoveryUtil.discover_fastapi_instances()
|
|
44
44
|
|
|
45
45
|
if not fastapi_apps:
|
|
46
46
|
# This is not necessarily an error - user might be using FastAPI differently
|
|
@@ -90,169 +90,6 @@ class FastAPIAppDiscoveryStep(PipelineStep):
|
|
|
90
90
|
|
|
91
91
|
return result
|
|
92
92
|
|
|
93
|
-
def _discover_fastapi_instances(self) -> Dict[str, Dict[str, Any]]:
|
|
94
|
-
"""
|
|
95
|
-
Discover FastAPI application instances in the Python runtime.
|
|
96
|
-
|
|
97
|
-
Uses intelligent deduplication to handle standard uvicorn patterns where
|
|
98
|
-
the same app might be imported multiple times (e.g., "module:app" pattern).
|
|
99
|
-
|
|
100
|
-
Returns:
|
|
101
|
-
Dict mapping app_id -> app_info where app_info contains:
|
|
102
|
-
- 'instance': The FastAPI app instance
|
|
103
|
-
- 'title': App title from FastAPI
|
|
104
|
-
- 'routes': List of route information
|
|
105
|
-
- 'module': Module where app was found
|
|
106
|
-
"""
|
|
107
|
-
fastapi_apps = {}
|
|
108
|
-
seen_apps = {} # For deduplication: title -> app_info
|
|
109
|
-
|
|
110
|
-
try:
|
|
111
|
-
# Import FastAPI here to avoid dependency if not used
|
|
112
|
-
from fastapi import FastAPI
|
|
113
|
-
except ImportError:
|
|
114
|
-
self.logger.warning("FastAPI not installed - cannot discover FastAPI apps")
|
|
115
|
-
return {}
|
|
116
|
-
|
|
117
|
-
# Scan garbage collector for FastAPI instances
|
|
118
|
-
candidate_apps = []
|
|
119
|
-
for obj in gc.get_objects():
|
|
120
|
-
if isinstance(obj, FastAPI):
|
|
121
|
-
candidate_apps.append(obj)
|
|
122
|
-
|
|
123
|
-
# Deduplicate apps with identical configurations
|
|
124
|
-
for obj in candidate_apps:
|
|
125
|
-
try:
|
|
126
|
-
title = getattr(obj, "title", "FastAPI App")
|
|
127
|
-
version = getattr(obj, "version", "unknown")
|
|
128
|
-
routes = self._extract_route_info(obj)
|
|
129
|
-
route_count = len(routes)
|
|
130
|
-
|
|
131
|
-
# Create a signature for deduplication
|
|
132
|
-
app_signature = (title, version, route_count)
|
|
133
|
-
|
|
134
|
-
# Check if we've seen an identical app
|
|
135
|
-
if app_signature in seen_apps:
|
|
136
|
-
existing_app = seen_apps[app_signature]
|
|
137
|
-
# Compare route details to ensure they're truly identical
|
|
138
|
-
existing_routes = existing_app["routes"]
|
|
139
|
-
|
|
140
|
-
if self._routes_are_identical(routes, existing_routes):
|
|
141
|
-
self.logger.debug(
|
|
142
|
-
f"Skipping duplicate FastAPI app: '{title}' (same title, version, and routes)"
|
|
143
|
-
)
|
|
144
|
-
continue # Skip this duplicate
|
|
145
|
-
|
|
146
|
-
# This is a unique app, add it
|
|
147
|
-
app_id = f"app_{id(obj)}"
|
|
148
|
-
app_info = {
|
|
149
|
-
"instance": obj,
|
|
150
|
-
"title": title,
|
|
151
|
-
"version": version,
|
|
152
|
-
"routes": routes,
|
|
153
|
-
"module": self._get_app_module(obj),
|
|
154
|
-
"object_id": id(obj),
|
|
155
|
-
"router_routes_count": len(obj.router.routes) if hasattr(obj, 'router') else 0,
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
fastapi_apps[app_id] = app_info
|
|
159
|
-
seen_apps[app_signature] = app_info
|
|
160
|
-
|
|
161
|
-
self.logger.debug(
|
|
162
|
-
f"Found FastAPI app: '{title}' (module: {app_info['module']}) with "
|
|
163
|
-
f"{len(routes)} routes"
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
except Exception as e:
|
|
167
|
-
self.logger.warning(f"Error analyzing FastAPI app: {e}")
|
|
168
|
-
continue
|
|
169
|
-
|
|
170
|
-
return fastapi_apps
|
|
171
|
-
|
|
172
|
-
def _routes_are_identical(self, routes1: List[Dict[str, Any]], routes2: List[Dict[str, Any]]) -> bool:
|
|
173
|
-
"""
|
|
174
|
-
Compare two route lists to see if they're identical.
|
|
175
|
-
|
|
176
|
-
Args:
|
|
177
|
-
routes1: First route list
|
|
178
|
-
routes2: Second route list
|
|
179
|
-
|
|
180
|
-
Returns:
|
|
181
|
-
True if routes are identical, False otherwise
|
|
182
|
-
"""
|
|
183
|
-
if len(routes1) != len(routes2):
|
|
184
|
-
return False
|
|
185
|
-
|
|
186
|
-
# Create comparable signatures for each route
|
|
187
|
-
def route_signature(route):
|
|
188
|
-
return (
|
|
189
|
-
tuple(sorted(route.get('methods', []))), # Sort methods for consistent comparison
|
|
190
|
-
route.get('path', ''),
|
|
191
|
-
route.get('endpoint_name', '')
|
|
192
|
-
)
|
|
193
|
-
|
|
194
|
-
# Sort routes by signature for consistent comparison
|
|
195
|
-
sig1 = sorted([route_signature(r) for r in routes1])
|
|
196
|
-
sig2 = sorted([route_signature(r) for r in routes2])
|
|
197
|
-
|
|
198
|
-
return sig1 == sig2
|
|
199
|
-
|
|
200
|
-
def _extract_route_info(self, app) -> List[Dict[str, Any]]:
|
|
201
|
-
"""
|
|
202
|
-
Extract route information from FastAPI app without modifying it.
|
|
203
|
-
|
|
204
|
-
Args:
|
|
205
|
-
app: FastAPI application instance
|
|
206
|
-
|
|
207
|
-
Returns:
|
|
208
|
-
List of route information dictionaries
|
|
209
|
-
"""
|
|
210
|
-
routes = []
|
|
211
|
-
|
|
212
|
-
try:
|
|
213
|
-
for route in app.router.routes:
|
|
214
|
-
if hasattr(route, 'endpoint') and hasattr(route, 'path'):
|
|
215
|
-
route_info = {
|
|
216
|
-
"path": route.path,
|
|
217
|
-
"methods": list(route.methods) if hasattr(route, 'methods') else [],
|
|
218
|
-
"endpoint": route.endpoint,
|
|
219
|
-
"endpoint_name": getattr(route.endpoint, '__name__', 'unknown'),
|
|
220
|
-
"has_mesh_route": hasattr(route.endpoint, '_mesh_route_metadata'),
|
|
221
|
-
}
|
|
222
|
-
routes.append(route_info)
|
|
223
|
-
|
|
224
|
-
except Exception as e:
|
|
225
|
-
self.logger.warning(f"Error extracting route info: {e}")
|
|
226
|
-
|
|
227
|
-
return routes
|
|
228
|
-
|
|
229
|
-
def _get_app_module(self, app) -> Optional[str]:
|
|
230
|
-
"""
|
|
231
|
-
Try to determine which module the FastAPI app belongs to.
|
|
232
|
-
|
|
233
|
-
Args:
|
|
234
|
-
app: FastAPI application instance
|
|
235
|
-
|
|
236
|
-
Returns:
|
|
237
|
-
Module name or None if unknown
|
|
238
|
-
"""
|
|
239
|
-
try:
|
|
240
|
-
# Try to get module from the app's stack frame when it was created
|
|
241
|
-
# This is best-effort - may not always work
|
|
242
|
-
import inspect
|
|
243
|
-
|
|
244
|
-
frame = inspect.currentframe()
|
|
245
|
-
while frame:
|
|
246
|
-
frame_globals = frame.f_globals
|
|
247
|
-
for name, obj in frame_globals.items():
|
|
248
|
-
if obj is app:
|
|
249
|
-
return frame_globals.get('__name__', 'unknown')
|
|
250
|
-
frame = frame.f_back
|
|
251
|
-
|
|
252
|
-
except Exception:
|
|
253
|
-
pass
|
|
254
|
-
|
|
255
|
-
return None
|
|
256
93
|
|
|
257
94
|
def _map_routes_to_apps(
|
|
258
95
|
self,
|
|
@@ -43,6 +43,8 @@ class HeartbeatOrchestrator:
|
|
|
43
43
|
self._heartbeat_count += 1
|
|
44
44
|
|
|
45
45
|
try:
|
|
46
|
+
|
|
47
|
+
|
|
46
48
|
# Prepare heartbeat context with validation
|
|
47
49
|
heartbeat_context = self._prepare_heartbeat_context(agent_id, context)
|
|
48
50
|
|
|
@@ -65,10 +67,12 @@ class HeartbeatOrchestrator:
|
|
|
65
67
|
import asyncio
|
|
66
68
|
|
|
67
69
|
try:
|
|
70
|
+
|
|
68
71
|
result = await asyncio.wait_for(
|
|
69
72
|
self.pipeline.execute_heartbeat_cycle(heartbeat_context),
|
|
70
73
|
timeout=30.0,
|
|
71
74
|
)
|
|
75
|
+
|
|
72
76
|
except TimeoutError:
|
|
73
77
|
self.logger.error(
|
|
74
78
|
f"❌ Heartbeat #{self._heartbeat_count} timed out after 30 seconds"
|
|
@@ -44,6 +44,19 @@ async def heartbeat_lifespan_task(heartbeat_config: dict[str, Any]) -> None:
|
|
|
44
44
|
|
|
45
45
|
try:
|
|
46
46
|
while True:
|
|
47
|
+
# Check if shutdown is complete before executing heartbeat
|
|
48
|
+
try:
|
|
49
|
+
from ...shared.simple_shutdown import should_stop_heartbeat
|
|
50
|
+
|
|
51
|
+
if should_stop_heartbeat():
|
|
52
|
+
logger.info(
|
|
53
|
+
f"🛑 Heartbeat stopped for agent '{agent_id}' due to shutdown"
|
|
54
|
+
)
|
|
55
|
+
break
|
|
56
|
+
except ImportError:
|
|
57
|
+
# If simple_shutdown is not available, continue normally
|
|
58
|
+
pass
|
|
59
|
+
|
|
47
60
|
try:
|
|
48
61
|
# Execute heartbeat pipeline
|
|
49
62
|
success = await heartbeat_orchestrator.execute_heartbeat(
|
|
@@ -13,6 +13,7 @@ from .heartbeat_loop import HeartbeatLoopStep
|
|
|
13
13
|
from .heartbeat_preparation import HeartbeatPreparationStep
|
|
14
14
|
from .startup_orchestrator import (
|
|
15
15
|
MeshOrchestrator,
|
|
16
|
+
clear_debounce_coordinator,
|
|
16
17
|
get_debounce_coordinator,
|
|
17
18
|
get_global_orchestrator,
|
|
18
19
|
start_runtime,
|
|
@@ -28,6 +29,7 @@ __all__ = [
|
|
|
28
29
|
"HeartbeatPreparationStep",
|
|
29
30
|
"MeshOrchestrator",
|
|
30
31
|
"StartupPipeline",
|
|
32
|
+
"clear_debounce_coordinator",
|
|
31
33
|
"get_global_orchestrator",
|
|
32
34
|
"get_debounce_coordinator",
|
|
33
35
|
"start_runtime",
|