Skip to content

Commit dd2f5f9

Browse files
committed
Improve Sidecar startup and health monitoring
1 parent ccf0024 commit dd2f5f9

12 files changed

Lines changed: 268 additions & 70 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ npm run dev
123123

124124
# 独立 Sidecar(可选;Tauri 默认 auto_start_sidecar 会自动预热)
125125
cd agent
126-
python -m uvicorn app.main:app --host 127.0.0.1 --port 9527
126+
python run.py
127127
```
128128

129129
首次使用:在 **设置 → 模型** 中添加 Provider API Key,然后在 **Chat** 新建会话即可对话。

agent/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Python Sidecar for MisakaX, providing LangGraph Agent orchestration and PowerMem
66

77
```bash
88
pip install -r requirements.txt
9-
uvicorn app.main:app --host 127.0.0.1 --port 9527
9+
python run.py
1010
```
1111

1212
## Health Check

agent/app/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from app.config import bridge_provider_api_keys, get_settings
99
from app.dependencies import close_checkpointer, setup_checkpointer
1010
from app.routers.agent import router as agent_router
11-
from app.routers.health import router as health_router
11+
from app.routers.health import cache_health_capabilities, router as health_router
1212
from app.routers.info import router as info_router
1313
from app.routers.memory import router as memory_router
1414

@@ -17,6 +17,7 @@
1717
async def lifespan(application: FastAPI):
1818
"""Manage startup and shutdown lifecycle."""
1919
application.state.startup_time = time.time()
20+
cache_health_capabilities(application)
2021
bridge_provider_api_keys()
2122
await setup_checkpointer()
2223
try:

agent/app/routers/health.py

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
1-
"""Health check endpoint with enhanced status reporting."""
1+
"""Health check endpoint with cached startup capability reporting."""
22

33
import time
4+
from dataclasses import dataclass
45

5-
from fastapi import APIRouter, Request
6+
from fastapi import APIRouter, FastAPI, Request
67

78
from app.models import HealthResponse
89

910
router = APIRouter()
1011

1112
SIDECAR_VERSION = "0.1.0"
13+
_HEALTH_CAPABILITIES_STATE_KEY = "health_capabilities"
14+
15+
16+
@dataclass(frozen=True)
17+
class HealthCapabilities:
18+
"""Static Sidecar capabilities determined once during application startup."""
19+
20+
agent_ready: bool
21+
capabilities: tuple[str, ...]
1222

1323

1424
def _agent_module_ready() -> bool:
@@ -47,27 +57,45 @@ def _powermem_available() -> bool:
4757
return False
4858

4959

60+
def detect_health_capabilities() -> HealthCapabilities:
61+
"""Inspect optional integrations once; installed packages do not change at runtime."""
62+
agent_ready = _agent_module_ready() and (_deepagents_available() or _langgraph_available())
63+
capabilities: list[str] = ["health", "info"]
64+
if agent_ready:
65+
capabilities.extend(["agent", "agent_stream"])
66+
if _powermem_available():
67+
capabilities.append("memory")
68+
return HealthCapabilities(agent_ready=agent_ready, capabilities=tuple(capabilities))
69+
70+
71+
def cache_health_capabilities(application: FastAPI) -> HealthCapabilities:
72+
"""Populate the application-level health capability snapshot."""
73+
capabilities = detect_health_capabilities()
74+
setattr(application.state, _HEALTH_CAPABILITIES_STATE_KEY, capabilities)
75+
return capabilities
76+
77+
78+
def get_cached_health_capabilities(application: FastAPI) -> HealthCapabilities:
79+
"""Read the startup snapshot, with a safe fallback for direct ASGI tests."""
80+
cached = getattr(application.state, _HEALTH_CAPABILITIES_STATE_KEY, None)
81+
if isinstance(cached, HealthCapabilities):
82+
return cached
83+
return cache_health_capabilities(application)
84+
85+
5086
@router.get("/health", response_model=HealthResponse)
5187
async def health_check(request: Request) -> HealthResponse:
5288
"""Return health status with version, uptime, and capabilities."""
5389
startup_time: float = getattr(request.app.state, "startup_time", 0.0)
5490
uptime = time.time() - startup_time if startup_time > 0 else 0.0
5591

56-
agent_ready = _agent_module_ready() and (
57-
_deepagents_available() or _langgraph_available()
58-
)
59-
capabilities: list[str] = ["health", "info"]
60-
if agent_ready:
61-
capabilities.append("agent")
62-
capabilities.append("agent_stream")
63-
if _powermem_available():
64-
capabilities.append("memory")
92+
health_capabilities = get_cached_health_capabilities(request.app)
6593

6694
return HealthResponse(
6795
status="ok",
6896
service="misaka-agent",
6997
version=SIDECAR_VERSION,
7098
uptime_seconds=round(uptime, 2),
71-
capabilities=capabilities,
72-
agent_ready=agent_ready,
99+
capabilities=list(health_capabilities.capabilities),
100+
agent_ready=health_capabilities.agent_ready,
73101
)

agent/run.py

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""Standalone entry point for Nuitka builds.
22
33
When compiled with Nuitka (--standalone / --onefile), the app object must be
4-
passed directly to uvicorn.run() instead of using the "app.main:app" string
5-
form, because Nuitka cannot resolve module-string imports at runtime.
4+
passed directly to Uvicorn instead of using the "app.main:app" string form,
5+
because Nuitka cannot resolve module-string imports at runtime.
66
"""
77

88
import multiprocessing
9+
import logging
910
import os
1011
import sys
1112
import types
@@ -62,20 +63,60 @@ def _get_windows_console_stream(_stream, _encoding, _errors): # noqa: ANN001, A
6263

6364
import uvicorn # noqa: E402 (import after DLL dir registration)
6465

65-
from app.config import get_settings # noqa: E402
66+
from app.config import Settings, get_settings # noqa: E402
6667
from app.main import app # noqa: E402
6768

6869

69-
def main() -> None:
70-
multiprocessing.freeze_support()
71-
settings = get_settings()
72-
uvicorn.run(
70+
SIDECAR_HTTP_KEEP_ALIVE_SECONDS = 35
71+
72+
73+
class SuccessfulHealthCheckAccessFilter(logging.Filter):
74+
"""Suppress only successful local health checks from Uvicorn access logs."""
75+
76+
def filter(self, record: logging.LogRecord) -> bool:
77+
args = record.args
78+
if not isinstance(args, tuple) or len(args) < 5:
79+
return True
80+
81+
try:
82+
method = str(args[1]).upper()
83+
path = str(args[2]).split("?", maxsplit=1)[0]
84+
status_code = int(args[4])
85+
except (TypeError, ValueError):
86+
return True
87+
88+
return not (method in {"GET", "HEAD"} and path == "/health" and 200 <= status_code < 300)
89+
90+
91+
def configure_access_log_filter() -> None:
92+
"""Attach the health-check filter once without disturbing other handlers."""
93+
access_logger = logging.getLogger("uvicorn.access")
94+
if not any(
95+
isinstance(log_filter, SuccessfulHealthCheckAccessFilter)
96+
for log_filter in access_logger.filters
97+
):
98+
access_logger.addFilter(SuccessfulHealthCheckAccessFilter())
99+
100+
101+
def build_uvicorn_config(settings: Settings) -> uvicorn.Config:
102+
"""Build the common development and packaged Sidecar server configuration."""
103+
return uvicorn.Config(
73104
app,
74105
host=settings.host,
75106
port=settings.port,
76107
log_level=settings.log_level,
108+
timeout_keep_alive=SIDECAR_HTTP_KEEP_ALIVE_SECONDS,
109+
access_log=True,
77110
)
78111

79112

113+
def main() -> None:
114+
multiprocessing.freeze_support()
115+
settings = get_settings()
116+
config = build_uvicorn_config(settings)
117+
configure_access_log_filter()
118+
uvicorn.Server(config).run()
119+
120+
80121
if __name__ == "__main__":
81122
sys.exit(main() or 0)

agent/tests/test_health.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
"""Tests for the health check endpoint."""
22

33
import pytest
4+
from fastapi import FastAPI
5+
6+
from app.routers import health
47

58

69
@pytest.mark.asyncio
@@ -29,6 +32,36 @@ async def test_health_check_has_enhanced_fields(client):
2932
assert "agent_stream" in data["capabilities"]
3033

3134

35+
def test_health_capabilities_are_cached(monkeypatch):
36+
application = FastAPI()
37+
calls = {"agent": 0, "deepagents": 0, "powermem": 0}
38+
39+
def agent_ready() -> bool:
40+
calls["agent"] += 1
41+
return True
42+
43+
def deepagents_available() -> bool:
44+
calls["deepagents"] += 1
45+
return True
46+
47+
def powermem_available() -> bool:
48+
calls["powermem"] += 1
49+
return True
50+
51+
monkeypatch.setattr(health, "_agent_module_ready", agent_ready)
52+
monkeypatch.setattr(health, "_deepagents_available", deepagents_available)
53+
monkeypatch.setattr(health, "_langgraph_available", lambda: False)
54+
monkeypatch.setattr(health, "_powermem_available", powermem_available)
55+
56+
first = health.get_cached_health_capabilities(application)
57+
second = health.get_cached_health_capabilities(application)
58+
59+
assert first is second
60+
assert first.agent_ready is True
61+
assert first.capabilities == ("health", "info", "agent", "agent_stream", "memory")
62+
assert calls == {"agent": 1, "deepagents": 1, "powermem": 1}
63+
64+
3265
@pytest.mark.asyncio
3366
async def test_health_check_is_get_only(client):
3467
response = await client.post("/health")

agent/tests/test_run.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Tests for the Sidecar Uvicorn entry-point configuration."""
2+
3+
import logging
4+
5+
from app.config import Settings
6+
from run import (
7+
SIDECAR_HTTP_KEEP_ALIVE_SECONDS,
8+
SuccessfulHealthCheckAccessFilter,
9+
build_uvicorn_config,
10+
)
11+
12+
13+
def _access_record(method: str, path: str, status_code: int) -> logging.LogRecord:
14+
return logging.LogRecord(
15+
"uvicorn.access",
16+
logging.INFO,
17+
__file__,
18+
1,
19+
'%s - "%s %s HTTP/%s" %d',
20+
("127.0.0.1:12345", method, path, "1.1", status_code),
21+
None,
22+
)
23+
24+
25+
def test_successful_health_access_logs_are_filtered():
26+
log_filter = SuccessfulHealthCheckAccessFilter()
27+
28+
assert not log_filter.filter(_access_record("GET", "/health", 200))
29+
assert not log_filter.filter(_access_record("HEAD", "/health?verbose=1", 204))
30+
assert log_filter.filter(_access_record("GET", "/health", 500))
31+
assert log_filter.filter(_access_record("GET", "/agent/chat", 200))
32+
33+
34+
def test_uvicorn_config_keeps_local_connections_alive():
35+
config = build_uvicorn_config(Settings(host="127.0.0.1", port=9527))
36+
37+
assert config.timeout_keep_alive == SIDECAR_HTTP_KEEP_ALIVE_SECONDS
38+
assert config.access_log is True

docs/guides/rust-learning-faq-modules-and-lib.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ Rust 把名字分成多种 **命名空间(namespace)**(类型的、值的
322322
**`start` 在仓库里实际做了什么**(与 `CLAUDE.md` 里「Python Sidecar (:9527) + uvicorn」一致,实现见 `sidecar.rs`):
323323

324324
1. **健康检查**:对 **`http://127.0.0.1:{port}/health`** 发 GET;若已成功响应,认为 Sidecar 已在跑,**返回 `Ok(Self { child: None })`**(不再重复拉起进程)。
325-
2. **否则 spawn 子进程**:在当前机子上执行形如 **`python -m uvicorn app.main:app --host 127.0.0.1 --port <端口>`****工作目录**设为传入的 **`agent_dir`**(即仓库里的 **`agent/`** Python 工程,里面要有 `app.main:app`)。
325+
2. **否则 spawn 子进程**:在当前机子上执行形如 **`python <agent_dir>/run.py`**,并通过 `MISAKA_HOST=127.0.0.1``MISAKA_PORT=<port>` 传入监听地址;**工作目录**设为传入的 **`agent_dir`**(即仓库里的 **`agent/`** Python 工程)。
326326
3. **轮询等待**:最多约 **10 秒**,直到 **`/health`** 成功;成功则 **`Ok(Self { child: Some(child) })`**,把子进程放进管理器里。
327327
4. **超时**:杀掉子进程,**`Err(String)`** 说明健康检查超时。
328328
5. **`SidecarManager` 被 drop 时**(例如 `AppState` 释放:**`impl Drop`**):若 **`child``Some`**,会 **kill + wait**,避免僵尸进程。

docs/guides/sidecar-nuitka-build.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ Anaconda 的 `_ssl.pyd` 依赖 `libcrypto-3-x64.dll` / `libssl-3-x64.dll`。`bui
108108
1. `agent/dist/misaka-agent.exe`(Windows)或 `agent/dist/misaka-agent`
109109
2. 当前应用 exe 同级目录的 `misaka-agent(.exe)`
110110

111-
找到二进制时,Rust 使用 `MISAKA_HOST=127.0.0.1``MISAKA_PORT=<configured port>` 启动它;未找到时回退到开发模式的 `python -m uvicorn app.main:app --host 127.0.0.1 --port <port>`
111+
找到二进制时,Rust 使用 `MISAKA_HOST=127.0.0.1``MISAKA_PORT=<configured port>` 启动它;未找到时回退到开发模式的 `python <agent-dir>/run.py`,并通过相同环境变量传递 host port。
112112

113113
因此:
114114

0 commit comments

Comments
 (0)