Stabilize OpenSpace MCP daemon lifecycle

This commit is contained in:
CCLCK 2026-04-14 22:47:05 +08:00
parent 15bd9bbc25
commit 130a780e7f
14 changed files with 1598 additions and 56 deletions

View file

@ -0,0 +1,627 @@
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import signal
import subprocess
import tempfile
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
OPENING_PREFIX = "先做一次 OpenSpace 预检,再开始当前任务。"
SESSION_STATUS_RE = re.compile(r"OpenSpace session:\s*(\S+)")
MACHINE_STATUS_RE = re.compile(r"OpenSpace machine:\s*(\S+)")
FALLBACK_LINE = "当前线程不依赖 OpenSpace 自动沉淀;我会先按本地文档、脚本或手动收尾路径继续。"
@dataclass(frozen=True)
class OpeningStatus:
session_status: str
machine_status: str
fallback_present: bool
text: str
@dataclass(frozen=True)
class ExecOutput:
thread_id: str | None
events: list[dict[str, Any]]
agent_messages: list[str]
opening: OpeningStatus | None
@dataclass(frozen=True)
class SessionFamily:
thread_ids: set[str]
session_files: set[Path]
@dataclass(frozen=True)
class RunResult:
label: str
exit_code: int
thread_id: str | None
opening: dict[str, Any] | None
agent_messages: list[str]
mcp_tool_calls: list[dict[str, Any]]
session_files: list[str]
thread_ids: list[str]
stdout_path: str
stderr_path: str
@dataclass(frozen=True)
class ScenarioResult:
name: str
ok: bool
details: dict[str, Any]
def _iter_json_events(output: str) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
for raw_line in output.splitlines():
line = raw_line.strip()
if not line.startswith("{"):
continue
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(payload, dict):
events.append(payload)
return events
def _extract_agent_message(event: dict[str, Any]) -> str | None:
if event.get("type") != "item.completed":
return None
item = event.get("item")
if not isinstance(item, dict):
return None
if item.get("type") != "agent_message":
return None
text = item.get("text")
return text if isinstance(text, str) else None
def _extract_mcp_tool_call(event: dict[str, Any]) -> dict[str, Any] | None:
if event.get("type") != "item.completed":
return None
item = event.get("item")
if not isinstance(item, dict):
return None
if item.get("type") != "mcp_tool_call":
return None
return {
"server": item.get("server"),
"tool": item.get("tool"),
"status": item.get("status"),
"error": item.get("error"),
}
def _parse_opening(text: str) -> OpeningStatus | None:
if not text.startswith(OPENING_PREFIX):
return None
session_match = SESSION_STATUS_RE.search(text)
machine_match = MACHINE_STATUS_RE.search(text)
if session_match is None or machine_match is None:
return None
return OpeningStatus(
session_status=session_match.group(1),
machine_status=machine_match.group(1),
fallback_present=FALLBACK_LINE in text,
text=text,
)
def parse_exec_output(output: str) -> ExecOutput:
events = _iter_json_events(output)
thread_id: str | None = None
agent_messages: list[str] = []
opening: OpeningStatus | None = None
for event in events:
if event.get("type") == "thread.started" and thread_id is None:
candidate = event.get("thread_id")
if isinstance(candidate, str):
thread_id = candidate
agent_message = _extract_agent_message(event)
if agent_message is not None:
agent_messages.append(agent_message)
if opening is None:
opening = _parse_opening(agent_message)
return ExecOutput(
thread_id=thread_id,
events=events,
agent_messages=agent_messages,
opening=opening,
)
def _read_session_meta(path: Path) -> dict[str, Any] | None:
try:
first_line = path.read_text(encoding="utf-8").splitlines()[0]
except Exception:
return None
try:
payload = json.loads(first_line)
except json.JSONDecodeError:
return None
if payload.get("type") != "session_meta":
return None
session_payload = payload.get("payload")
return session_payload if isinstance(session_payload, dict) else None
def collect_session_family(thread_id: str, sessions_root: Path) -> SessionFamily:
metas: list[tuple[Path, dict[str, Any]]] = []
for path in sessions_root.glob("**/*.jsonl"):
meta = _read_session_meta(path)
if meta is not None:
metas.append((path, meta))
thread_ids: set[str] = {thread_id}
session_files: set[Path] = set()
changed = True
while changed:
changed = False
for path, meta in metas:
current_id = meta.get("id")
if isinstance(current_id, str) and current_id in thread_ids:
if path not in session_files:
session_files.add(path)
changed = True
source = meta.get("source")
if not isinstance(source, dict):
continue
subagent = source.get("subagent")
if not isinstance(subagent, dict):
continue
thread_spawn = subagent.get("thread_spawn")
if not isinstance(thread_spawn, dict):
continue
parent_thread_id = thread_spawn.get("parent_thread_id")
if (
isinstance(parent_thread_id, str)
and parent_thread_id in thread_ids
and isinstance(current_id, str)
and current_id not in thread_ids
):
thread_ids.add(current_id)
session_files.add(path)
changed = True
return SessionFamily(thread_ids=thread_ids, session_files=session_files)
def cleanup_session_artifacts(
*,
thread_ids: set[str],
session_files: set[Path],
session_index_path: Path,
) -> None:
for path in session_files:
path.unlink(missing_ok=True)
if not session_index_path.exists():
return
kept_lines: list[str] = []
for line in session_index_path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped:
continue
try:
payload = json.loads(stripped)
except json.JSONDecodeError:
kept_lines.append(line)
continue
payload_thread_id = payload.get("thread_id")
payload_session_file = payload.get("session_file")
if payload_thread_id in thread_ids:
continue
if isinstance(payload_session_file, str) and Path(payload_session_file) in session_files:
continue
kept_lines.append(line)
new_content = "\n".join(kept_lines)
if new_content:
new_content += "\n"
session_index_path.write_text(new_content, encoding="utf-8")
def snapshot_daemons(state_dir: Path, workspace: str) -> dict[str, dict[str, Any]]:
snapshot: dict[str, dict[str, Any]] = {}
if not state_dir.exists():
return snapshot
for path in state_dir.glob("*.json"):
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
if payload.get("workspace") != workspace:
continue
server_kind = payload.get("server_kind")
if not isinstance(server_kind, str):
continue
snapshot[server_kind] = payload
return snapshot
def kill_daemons_in_state_dir(state_dir: Path) -> None:
if not state_dir.exists():
return
for path in state_dir.glob("*.json"):
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
pid = payload.get("pid")
if not isinstance(pid, int):
continue
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
pass
except PermissionError:
pass
else:
deadline = time.time() + 2.0
while time.time() < deadline:
try:
os.kill(pid, 0)
except ProcessLookupError:
break
time.sleep(0.05)
else:
try:
os.kill(pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, AttributeError):
pass
shutil.rmtree(state_dir, ignore_errors=True)
def run_exec_session(
*,
label: str,
prompt: str,
cwd: Path,
extra_configs: list[str] | None = None,
timeout_seconds: int = 180,
codex_binary: str = "codex",
sessions_root: Path | None = None,
session_index_path: Path | None = None,
output_dir: Path | None = None,
) -> RunResult:
sessions_root = sessions_root or (Path.home() / ".codex" / "sessions")
session_index_path = session_index_path or (Path.home() / ".codex" / "session_index.jsonl")
output_dir = output_dir or Path(tempfile.mkdtemp(prefix="openspace-codex-session-"))
output_dir.mkdir(parents=True, exist_ok=True)
stdout_path = output_dir / f"{label}.stdout.log"
stderr_path = output_dir / f"{label}.stderr.log"
command = [
codex_binary,
"exec",
"--json",
"--skip-git-repo-check",
"--dangerously-bypass-approvals-and-sandbox",
"-C",
str(cwd),
]
for item in extra_configs or []:
command.extend(["-c", item])
command.append(prompt)
completed = subprocess.run(
command,
capture_output=True,
text=True,
errors="replace",
timeout=timeout_seconds,
check=False,
)
stdout_path.write_text(completed.stdout, encoding="utf-8")
stderr_path.write_text(completed.stderr, encoding="utf-8")
parsed = parse_exec_output(completed.stdout)
session_family = (
collect_session_family(parsed.thread_id, sessions_root)
if parsed.thread_id
else SessionFamily(thread_ids=set(), session_files=set())
)
mcp_tool_calls = [
call
for event in parsed.events
if (call := _extract_mcp_tool_call(event)) is not None
]
return RunResult(
label=label,
exit_code=completed.returncode,
thread_id=parsed.thread_id,
opening=asdict(parsed.opening) if parsed.opening else None,
agent_messages=parsed.agent_messages,
mcp_tool_calls=mcp_tool_calls,
session_files=sorted(str(path) for path in session_family.session_files),
thread_ids=sorted(session_family.thread_ids),
stdout_path=str(stdout_path),
stderr_path=str(stderr_path),
)
def _session_env_overrides(state_dir: Path, workspace: Path) -> list[str]:
return [
f'mcp_servers.openspace.env.OPENSPACE_MCP_DAEMON_STATE_DIR="{state_dir}"',
f'mcp_servers.openspace_evolution.env.OPENSPACE_MCP_DAEMON_STATE_DIR="{state_dir}"',
'mcp_servers.openspace.env.OPENSPACE_MCP_PROXY_MODE="daemon"',
'mcp_servers.openspace_evolution.env.OPENSPACE_MCP_PROXY_MODE="daemon"',
f'mcp_servers.openspace.env.OPENSPACE_WORKSPACE="{workspace}"',
f'mcp_servers.openspace_evolution.env.OPENSPACE_WORKSPACE="{workspace}"',
]
def _assert(condition: bool, message: str, errors: list[str]) -> None:
if not condition:
errors.append(message)
def _scenario_cold_start(base_dir: Path, cwd: Path) -> ScenarioResult:
state_dir = base_dir / "cold-start-state"
run = run_exec_session(
label="cold-start",
prompt=(
"这是 OpenSpace 冷启动预检测试。严格按 TMP 的 AGENTS.md 做开场预检,"
"然后只输出一句“cold-start done”。不要修改任何文件也不要使用子代理。"
),
cwd=cwd,
extra_configs=_session_env_overrides(state_dir, cwd),
output_dir=base_dir / "cold-start-output",
)
errors: list[str] = []
opening = run.opening or {}
_assert(run.exit_code == 0, f"cold-start exit_code={run.exit_code}", errors)
_assert(opening.get("session_status") == "ready", f"cold-start session_status={opening.get('session_status')}", errors)
_assert(opening.get("machine_status") == "ready", f"cold-start machine_status={opening.get('machine_status')}", errors)
_assert(opening.get("fallback_present") is False, "cold-start unexpectedly printed fallback line", errors)
_assert(run.agent_messages[-1:] == ["cold-start done"], f"cold-start final message={run.agent_messages[-1:]}", errors)
return ScenarioResult(
name="cold-start-preflight",
ok=not errors,
details={"errors": errors, "run": asdict(run), "state_dirs": [str(state_dir)]},
)
def _scenario_warm_reuse(base_dir: Path, cwd: Path) -> ScenarioResult:
state_dir = base_dir / "warm-reuse-state"
extra_configs = _session_env_overrides(state_dir, cwd)
prompt = (
"这是 OpenSpace warm-session reuse 测试。先完成 TMP 的开场预检,"
"然后调用 openspace 的 search_skills 工具,参数用 query='OpenSpace MCP 健康检查'"
"source='local'、limit=1、auto_import=false。最后只输出一句“warm-session done”。"
"不要修改任何文件,也不要使用子代理。"
)
first = run_exec_session(
label="warm-reuse-first",
prompt=prompt,
cwd=cwd,
extra_configs=extra_configs,
output_dir=base_dir / "warm-reuse-output",
)
first_snapshot = snapshot_daemons(state_dir, str(cwd))
second = run_exec_session(
label="warm-reuse-second",
prompt=prompt,
cwd=cwd,
extra_configs=extra_configs,
output_dir=base_dir / "warm-reuse-output",
)
second_snapshot = snapshot_daemons(state_dir, str(cwd))
errors: list[str] = []
for label, run in (("first", first), ("second", second)):
opening = run.opening or {}
_assert(run.exit_code == 0, f"warm-reuse {label} exit_code={run.exit_code}", errors)
_assert(opening.get("session_status") == "ready", f"warm-reuse {label} session_status={opening.get('session_status')}", errors)
_assert(any(call.get("server") == "openspace" and call.get("tool") == "search_skills" for call in run.mcp_tool_calls), f"warm-reuse {label} missing openspace.search_skills call", errors)
_assert(run.agent_messages[-1:] == ["warm-session done"], f"warm-reuse {label} final message={run.agent_messages[-1:]}", errors)
_assert("main" in first_snapshot, "warm-reuse first run did not create main daemon metadata", errors)
_assert("main" in second_snapshot, "warm-reuse second run did not create main daemon metadata", errors)
if "main" in first_snapshot and "main" in second_snapshot:
_assert(
first_snapshot["main"].get("pid") == second_snapshot["main"].get("pid"),
f"warm-reuse main pid changed: {first_snapshot['main'].get('pid')} -> {second_snapshot['main'].get('pid')}",
errors,
)
_assert(
first_snapshot["main"].get("port") == second_snapshot["main"].get("port"),
f"warm-reuse main port changed: {first_snapshot['main'].get('port')} -> {second_snapshot['main'].get('port')}",
errors,
)
return ScenarioResult(
name="warm-session-reuse",
ok=not errors,
details={
"errors": errors,
"first": asdict(first),
"second": asdict(second),
"first_snapshot": first_snapshot,
"second_snapshot": second_snapshot,
"state_dirs": [str(state_dir)],
},
)
def _scenario_unhealthy_fallback(base_dir: Path, cwd: Path) -> ScenarioResult:
state_dir = base_dir / "unhealthy-state"
missing_command = base_dir / "missing-openspace-command"
run = run_exec_session(
label="unhealthy-fallback",
prompt=(
"这是 OpenSpace unhealthy-session fallback 测试。严格按 TMP 的 AGENTS.md 做开场预检,"
"然后只输出一句“unhealthy-session done”。不要修改任何文件也不要使用子代理。"
),
cwd=cwd,
extra_configs=_session_env_overrides(state_dir, cwd)
+ [
f'mcp_servers.openspace.command="{missing_command}"',
f'mcp_servers.openspace_evolution.command="{missing_command}"',
],
output_dir=base_dir / "unhealthy-output",
)
errors: list[str] = []
opening = run.opening or {}
_assert(run.exit_code == 0, f"unhealthy fallback exit_code={run.exit_code}", errors)
_assert(
opening.get("session_status") in {"exposed-but-unhealthy", "unknown"},
f"unhealthy fallback session_status={opening.get('session_status')}",
errors,
)
_assert(opening.get("machine_status") == "ready", f"unhealthy fallback machine_status={opening.get('machine_status')}", errors)
_assert(opening.get("fallback_present") is True, "unhealthy fallback missing fallback line", errors)
_assert(run.agent_messages[-1:] == ["unhealthy-session done"], f"unhealthy fallback final message={run.agent_messages[-1:]}", errors)
return ScenarioResult(
name="unhealthy-session-fallback",
ok=not errors,
details={"errors": errors, "run": asdict(run), "state_dirs": [str(state_dir)]},
)
def _scenario_agent_team(base_dir: Path, cwd: Path) -> ScenarioResult:
state_dir = base_dir / "agent-team-state"
run = run_exec_session(
label="agent-team",
prompt=(
"这是 OpenSpace agent-team 测试。先完成 TMP 的开场预检,然后使用 agent team"
"至少启动两个只读子代理,分别检查 TMP 仓库里的 AGENTS.md 和 "
"scripts/check_openspace_mcp_preflight.py 与 OpenSpace 预检相关的内容。"
"父线程最后只输出一句“agent-team done”。不要修改任何文件。"
),
cwd=cwd,
extra_configs=_session_env_overrides(state_dir, cwd),
timeout_seconds=240,
output_dir=base_dir / "agent-team-output",
)
errors: list[str] = []
opening = run.opening or {}
_assert(run.exit_code == 0, f"agent-team exit_code={run.exit_code}", errors)
_assert(opening.get("session_status") == "ready", f"agent-team session_status={opening.get('session_status')}", errors)
_assert(len(run.thread_ids) >= 3, f"agent-team expected parent + >=2 child threads, got {run.thread_ids}", errors)
_assert(run.agent_messages[-1:] == ["agent-team done"], f"agent-team final message={run.agent_messages[-1:]}", errors)
return ScenarioResult(
name="agent-team-split",
ok=not errors,
details={"errors": errors, "run": asdict(run), "state_dirs": [str(state_dir)]},
)
def _cleanup_run_artifacts(result: ScenarioResult, session_index_path: Path) -> None:
details = result.details
candidate_runs: list[dict[str, Any]] = []
if "run" in details and isinstance(details["run"], dict):
candidate_runs.append(details["run"])
for key in ("first", "second"):
if key in details and isinstance(details[key], dict):
candidate_runs.append(details[key])
session_files: set[Path] = set()
thread_ids: set[str] = set()
for run in candidate_runs:
for item in run.get("session_files", []):
session_files.add(Path(item))
for item in run.get("thread_ids", []):
thread_ids.add(item)
cleanup_session_artifacts(
thread_ids=thread_ids,
session_files=session_files,
session_index_path=session_index_path,
)
def run_scenarios(*, cwd: Path, cleanup: bool = True) -> dict[str, Any]:
base_dir = Path(tempfile.mkdtemp(prefix="openspace-codex-scenarios-"))
session_index_path = Path.home() / ".codex" / "session_index.jsonl"
started_at = time.time()
scenario_steps = [
("cold-start-preflight", _scenario_cold_start),
("warm-session-reuse", _scenario_warm_reuse),
("unhealthy-session-fallback", _scenario_unhealthy_fallback),
("agent-team-split", _scenario_agent_team),
]
results: list[ScenarioResult] = []
try:
for scenario_name, scenario_fn in scenario_steps:
try:
results.append(scenario_fn(base_dir, cwd))
except Exception as exc: # noqa: BLE001
results.append(
ScenarioResult(
name=scenario_name,
ok=False,
details={"errors": [f"{type(exc).__name__}: {exc}"]},
)
)
finally:
if cleanup:
for result in results:
_cleanup_run_artifacts(result, session_index_path)
for state_dir in base_dir.glob("*-state"):
kill_daemons_in_state_dir(state_dir)
shutil.rmtree(base_dir, ignore_errors=True)
return {
"cwd": str(cwd),
"started_at": started_at,
"cleanup": cleanup,
"all_ok": all(item.ok for item in results),
"results": [asdict(item) for item in results],
}
def main() -> int:
parser = argparse.ArgumentParser(description="Run real Codex OpenSpace session scenarios")
parser.add_argument(
"--cwd",
type=Path,
default=Path("/Users/admin/PycharmProjects/TMP"),
help="Working directory for real session scenarios",
)
parser.add_argument(
"--keep-artifacts",
action="store_true",
help="Keep generated session files, daemon state, and command logs",
)
args = parser.parse_args()
summary = run_scenarios(cwd=args.cwd.resolve(), cleanup=not args.keep_artifacts)
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0 if summary["all_ok"] else 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -13,6 +13,7 @@ import json
import logging
import os
import subprocess
import signal
import sys
import threading
import time
@ -21,6 +22,7 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from openspace.mcp_stdio import maybe_redirect_stderr_to_file
from openspace.mcp_tool_registration import register_evolution_tools
class _MCPSafeStdout:
@ -82,11 +84,7 @@ _LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
_LOG_DIR.mkdir(parents=True, exist_ok=True)
_real_stdout = sys.stdout
if os.name == "nt":
_stderr_file = open(
_LOG_DIR / "evolution_mcp_stderr.log", "a", encoding="utf-8", buffering=1
)
sys.stderr = _stderr_file
maybe_redirect_stderr_to_file(_LOG_DIR, "evolution_mcp_stderr.log")
sys.stdout = _MCPSafeStdout(_real_stdout, sys.stderr)
@ -117,6 +115,8 @@ _idle_watchdog_started = False
_activity_lock = threading.Lock()
_active_request_count = 0
_last_activity_at = time.monotonic()
_shutdown_started = False
_shutdown_lock = threading.Lock()
def _json_ok(data: Any) -> str:
@ -132,6 +132,9 @@ def _mark_request_start() -> None:
with _activity_lock:
_active_request_count += 1
_last_activity_at = time.monotonic()
from openspace.shared_mcp_runtime import update_current_daemon_status
update_current_daemon_status("evolution", touch=True, active_delta=1)
def _mark_request_end() -> None:
@ -139,6 +142,51 @@ def _mark_request_end() -> None:
with _activity_lock:
_active_request_count = max(0, _active_request_count - 1)
_last_activity_at = time.monotonic()
from openspace.shared_mcp_runtime import update_current_daemon_status
update_current_daemon_status("evolution", touch=True, active_delta=-1)
def _shutdown_worker(reason: str) -> None:
logger.info("Shutting down OpenSpace evolution daemon: %s", reason)
instance = _openspace_instance
if instance is not None and instance.is_initialized():
try:
asyncio.run(asyncio.wait_for(instance.cleanup(), timeout=10.0))
except Exception as exc:
logger.warning("OpenSpace evolution cleanup during shutdown failed: %s", exc)
logging.shutdown()
os._exit(0)
def _begin_shutdown(reason: str) -> None:
global _shutdown_started
with _shutdown_lock:
if _shutdown_started:
return
_shutdown_started = True
threading.Thread(
target=_shutdown_worker,
args=(reason,),
name="openspace-evolution-shutdown",
daemon=True,
).start()
def _install_signal_handlers() -> None:
def _handle(signum, _frame) -> None:
try:
signame = signal.Signals(signum).name
except Exception:
signame = str(signum)
_begin_shutdown(f"signal {signame}")
for signum in (signal.SIGTERM, signal.SIGINT):
try:
signal.signal(signum, _handle)
except Exception:
continue
def _idle_watchdog_loop(idle_timeout_seconds: int) -> None:
@ -154,8 +202,8 @@ def _idle_watchdog_loop(idle_timeout_seconds: int) -> None:
"Evolution MCP idle watchdog exiting process after %.1fs idle with no active requests",
idle_for,
)
logging.shutdown()
os._exit(0)
_begin_shutdown(f"idle timeout after {idle_for:.1f}s")
return
def _maybe_start_idle_watchdog() -> None:
@ -700,6 +748,7 @@ def run_mcp_server() -> None:
args = parser.parse_args()
if args.transport == "stdio" or os.environ.get("OPENSPACE_MCP_DAEMON") == "1":
_install_signal_handlers()
_maybe_start_idle_watchdog()
mcp.settings.port = args.port

View file

@ -1,14 +1,17 @@
from __future__ import annotations
import asyncio
import argparse
import inspect
import json
import os
from pathlib import Path
from typing import Any
from mcp.server.fastmcp import FastMCP
from mcp.types import TextContent
from openspace.mcp_stdio import maybe_redirect_stderr_to_file
from openspace.grounding.backends.mcp.client import MCPClient
from openspace.mcp_tool_registration import (
register_evolution_tools,
@ -17,6 +20,10 @@ from openspace.mcp_tool_registration import (
from openspace.shared_mcp_runtime import ServerKind, ensure_daemon
_LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
maybe_redirect_stderr_to_file(_LOG_DIR, "mcp_proxy_stderr.log")
def _proxy_mode_for(server_kind: ServerKind) -> str:
raw = os.environ.get("OPENSPACE_MCP_PROXY_MODE", "").strip().lower()
if raw in {"daemon", "direct"}:
@ -45,45 +52,38 @@ def _extract_text_payload(result: Any) -> str:
class _RemoteProxyBase:
def __init__(self, server_kind: ServerKind):
self._server_kind = server_kind
self._client: MCPClient | None = None
self._current_url: str | None = None
async def _get_client(self) -> MCPClient:
async def _call_remote_tool_once(self, tool_name: str, args: dict[str, Any]) -> str:
record = await ensure_daemon(self._server_kind)
if self._client is not None and self._current_url == record.url:
return self._client
await self._reset_client()
self._client = MCPClient(
client = MCPClient(
config={"mcpServers": {"daemon": {"url": record.url}}},
timeout=10.0,
sse_read_timeout=60 * 60.0,
check_dependencies=False,
)
self._current_url = record.url
return self._client
try:
session = await client.create_session("daemon", auto_initialize=True)
if session is None:
raise RuntimeError("Failed to create daemon MCP session")
result = await session.connector.call_tool(tool_name, args)
return _extract_text_payload(result)
finally:
await client.close_all_sessions()
async def _reset_client(self) -> None:
if self._client is not None:
await self._client.close_all_sessions()
self._client = None
self._current_url = None
def _call_remote_tool_blocking(self, tool_name: str, args: dict[str, Any]) -> str:
return asyncio.run(self._call_remote_tool_once(tool_name, args))
async def _call_remote_tool(self, tool_name: str, args: dict[str, Any]) -> str:
for attempt in range(2):
try:
client = await self._get_client()
session = await client.create_session("daemon", auto_initialize=True)
if session is None:
raise RuntimeError("Failed to create daemon MCP session")
result = await session.connector.call_tool(tool_name, args)
return _extract_text_payload(result)
return await asyncio.to_thread(
self._call_remote_tool_blocking,
tool_name,
args,
)
except Exception as exc:
if attempt == 0:
await self._reset_client()
continue
return _json_error(exc, status="error")
if attempt == 1:
return _json_error(exc, status="error")
return _json_error("Unreachable proxy retry path", status="error")

View file

@ -21,12 +21,14 @@ import inspect
import json
import logging
import os
import signal
import sys
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from openspace.mcp_stdio import maybe_redirect_stderr_to_file
from openspace.mcp_tool_registration import register_main_tools
from openspace.shared_mcp_runtime import update_current_daemon_status
@ -89,17 +91,7 @@ _LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
_LOG_DIR.mkdir(parents=True, exist_ok=True)
_real_stdout = sys.stdout
# Windows pipe buffers are small. When using stdio MCP transport,
# the parent process only reads stdout for MCP messages and does NOT
# drain stderr. Heavy log/print output during execute_task fills the stderr
# pipe buffer, blocking this process on write() → deadlock → timeout.
# Redirect stderr to a log file on Windows to prevent this.
if os.name == "nt":
_stderr_file = open(
_LOG_DIR / "mcp_stderr.log", "a", encoding="utf-8", buffering=1
)
sys.stderr = _stderr_file
maybe_redirect_stderr_to_file(_LOG_DIR, "mcp_stderr.log")
sys.stdout = _MCPSafeStdout(_real_stdout, sys.stderr)
@ -132,6 +124,8 @@ _active_request_count = 0
_last_activity_at = time.monotonic()
_embedding_prewarm_started = False
_embedding_prewarm_lock = threading.Lock()
_shutdown_started = False
_shutdown_lock = threading.Lock()
# Internal state: tracks bot skill directories already registered this session.
_registered_skill_dirs: set = set()
@ -632,6 +626,7 @@ def _mark_request_start() -> None:
with _activity_lock:
_active_request_count += 1
_last_activity_at = time.monotonic()
update_current_daemon_status("main", touch=True, active_delta=1)
def _mark_request_end() -> None:
@ -639,6 +634,49 @@ def _mark_request_end() -> None:
with _activity_lock:
_active_request_count = max(0, _active_request_count - 1)
_last_activity_at = time.monotonic()
update_current_daemon_status("main", touch=True, active_delta=-1)
def _shutdown_worker(reason: str) -> None:
logger.info("Shutting down OpenSpace MCP daemon: %s", reason)
instance = _openspace_instance
if instance is not None and instance.is_initialized():
try:
asyncio.run(asyncio.wait_for(instance.cleanup(), timeout=10.0))
except Exception as exc:
logger.warning("OpenSpace MCP cleanup during shutdown failed: %s", exc)
logging.shutdown()
os._exit(0)
def _begin_shutdown(reason: str) -> None:
global _shutdown_started
with _shutdown_lock:
if _shutdown_started:
return
_shutdown_started = True
threading.Thread(
target=_shutdown_worker,
args=(reason,),
name="openspace-mcp-shutdown",
daemon=True,
).start()
def _install_signal_handlers() -> None:
def _handle(signum, _frame) -> None:
try:
signame = signal.Signals(signum).name
except Exception:
signame = str(signum)
_begin_shutdown(f"signal {signame}")
for signum in (signal.SIGTERM, signal.SIGINT):
try:
signal.signal(signum, _handle)
except Exception:
continue
def _idle_watchdog_loop(idle_timeout_seconds: int) -> None:
@ -654,8 +692,8 @@ def _idle_watchdog_loop(idle_timeout_seconds: int) -> None:
"MCP idle watchdog exiting process after %.1fs idle with no active requests",
idle_for,
)
logging.shutdown()
os._exit(0)
_begin_shutdown(f"idle timeout after {idle_for:.1f}s")
return
def _maybe_start_idle_watchdog() -> None:
@ -1142,6 +1180,7 @@ def run_mcp_server() -> None:
args = parser.parse_args()
if args.transport == "stdio" or os.environ.get("OPENSPACE_MCP_DAEMON") == "1":
_install_signal_handlers()
_maybe_start_idle_watchdog()
if args.transport == "streamable-http":
_maybe_start_main_daemon_embedding_prewarm()

42
openspace/mcp_stdio.py Normal file
View file

@ -0,0 +1,42 @@
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import TextIO
_STDERR_CAPTURE_HANDLE: TextIO | None = None
def maybe_redirect_stderr_to_file(log_dir: Path, filename: str) -> TextIO | None:
"""Redirect stderr to a log file when running as a non-interactive MCP child.
Codex and similar MCP hosts typically do not surface or continuously drain
child-process stderr. Leaving verbose transport logs attached to a pipe can
back up the buffer and stall stdio tool calls. For interactive terminals we
keep stderr unchanged so local debugging still behaves normally.
"""
global _STDERR_CAPTURE_HANDLE
if os.environ.get("OPENSPACE_MCP_CAPTURE_STDERR", "").strip().lower() in {
"0",
"false",
"no",
}:
return None
if _STDERR_CAPTURE_HANDLE is not None:
return _STDERR_CAPTURE_HANDLE
try:
if sys.stderr is not None and sys.stderr.isatty():
return None
except Exception:
pass
log_dir.mkdir(parents=True, exist_ok=True)
handle = (log_dir / filename).open("a", encoding="utf-8", buffering=1)
sys.stderr = handle
_STDERR_CAPTURE_HANDLE = handle
return handle

View file

@ -82,6 +82,8 @@ class MCPDaemonRecord:
ready_at: float | None = None
warmed_at: float | None = None
warmup_error: str | None = None
last_used_at: float | None = None
active_requests: int = 0
@property
def url(self) -> str:
@ -260,6 +262,82 @@ def _write_record(path: Path, record: MCPDaemonRecord) -> None:
tmp_path.replace(path)
def _record_last_used_at(record: MCPDaemonRecord) -> float:
return record.last_used_at or record.warmed_at or record.ready_at or record.started_at
def _max_daemons_per_kind() -> int:
raw = os.environ.get("OPENSPACE_MCP_MAX_DAEMONS_PER_KIND", "").strip()
if not raw:
return 8
try:
return int(raw)
except ValueError:
logger.warning("Invalid OPENSPACE_MCP_MAX_DAEMONS_PER_KIND=%r", raw)
return 8
def _unlink_record_artifacts(record: MCPDaemonRecord, state_dir: str) -> None:
metadata_path, lock_path = _metadata_paths(record.server_kind, record.instance_key, state_dir)
with contextlib.suppress(FileNotFoundError):
metadata_path.unlink()
with contextlib.suppress(FileNotFoundError):
lock_path.unlink()
def _collect_records(state_dir: str, server_kind: ServerKind) -> list[MCPDaemonRecord]:
state_path = Path(state_dir)
if not state_path.is_dir():
return []
records: list[MCPDaemonRecord] = []
for metadata_path in sorted(state_path.glob(f"{server_kind}-*.json")):
record = _read_record(metadata_path)
if record is not None:
records.append(record)
return records
def _reap_state_dir_records(
state_dir: str,
server_kind: ServerKind,
*,
keep_instance_key: str | None = None,
) -> None:
max_records = _max_daemons_per_kind()
live_records: list[MCPDaemonRecord] = []
for record in _collect_records(state_dir, server_kind):
if not _pid_exists(record.pid) or not _pid_matches_server(record):
_unlink_record_artifacts(record, state_dir)
continue
live_records.append(record)
if max_records <= 0 or len(live_records) <= max_records:
return
remaining = len(live_records)
for record in sorted(live_records, key=_record_last_used_at):
if remaining <= max_records:
break
if keep_instance_key and record.instance_key == keep_instance_key:
continue
if record.active_requests > 0:
continue
logger.info(
"Reaping %s daemon pid=%s instance_key=%s last_used_at=%.3f to keep fleet <= %s",
record.server_kind,
record.pid,
record.instance_key,
_record_last_used_at(record),
max_records,
)
_terminate_record_process(record)
_unlink_record_artifacts(record, state_dir)
remaining -= 1
def _metadata_paths(
server_kind: ServerKind,
instance_key: str,
@ -278,6 +356,8 @@ def update_current_daemon_status(
ready: bool | None = None,
warmed: bool | None = None,
warmup_error: str | None = None,
touch: bool = False,
active_delta: int = 0,
) -> MCPDaemonRecord | None:
instance_key = os.environ.get("OPENSPACE_MCP_INSTANCE_KEY", "").strip()
state_dir = os.environ.get("OPENSPACE_MCP_DAEMON_STATE_DIR", "").strip()
@ -303,6 +383,12 @@ def update_current_daemon_status(
if warmup_error is not None:
updates["warmup_error"] = warmup_error
if active_delta:
updates["active_requests"] = max(0, record.active_requests + active_delta)
touch = True
if touch:
updates["last_used_at"] = now
if not updates:
return record
@ -412,6 +498,7 @@ def _spawn_daemon(identity: MCPDaemonIdentity, port: int) -> MCPDaemonRecord:
**popen_kwargs,
)
log_handle.close()
started_at = time.time()
return MCPDaemonRecord(
server_kind=identity.server_kind,
instance_key=identity.instance_key,
@ -423,8 +510,9 @@ def _spawn_daemon(identity: MCPDaemonIdentity, port: int) -> MCPDaemonRecord:
backend_scope=list(identity.backend_scope),
host_skill_dirs=list(identity.host_skill_dirs),
grounding_config_fingerprint=identity.grounding_config_fingerprint,
started_at=time.time(),
started_at=started_at,
log_path=str(log_path),
last_used_at=started_at,
)
@ -466,10 +554,15 @@ async def ensure_daemon(server_kind: ServerKind) -> MCPDaemonRecord:
identity.metadata_path.parent.mkdir(parents=True, exist_ok=True)
with _FileLock(identity.lock_path):
_reap_state_dir_records(
identity.state_dir,
server_kind,
keep_instance_key=identity.instance_key,
)
existing = _read_record(identity.metadata_path)
if existing and _pid_exists(existing.pid) and await _probe_record(existing):
now = time.time()
if not existing.ready or (server_kind != "main" and not existing.warmed):
now = time.time()
refreshed = MCPDaemonRecord(
**{
**asdict(existing),
@ -480,16 +573,23 @@ async def ensure_daemon(server_kind: ServerKind) -> MCPDaemonRecord:
existing.warmed_at
or (now if (existing.warmed or server_kind != "main") else None)
),
"last_used_at": now,
}
)
_write_record(identity.metadata_path, refreshed)
return refreshed or existing
return existing
refreshed = MCPDaemonRecord(
**{
**asdict(existing),
"last_used_at": now,
}
)
_write_record(identity.metadata_path, refreshed)
return refreshed
if existing:
_terminate_record_process(existing)
with contextlib.suppress(FileNotFoundError):
identity.metadata_path.unlink()
_unlink_record_artifacts(existing, identity.state_dir)
last_error: Exception | None = None
for _ in range(3):
@ -504,6 +604,7 @@ async def ensure_daemon(server_kind: ServerKind) -> MCPDaemonRecord:
"ready_at": now,
"warmed": (server_kind != "main"),
"warmed_at": (now if server_kind != "main" else None),
"last_used_at": now,
}
)
_write_record(identity.metadata_path, updated)
@ -513,7 +614,6 @@ async def ensure_daemon(server_kind: ServerKind) -> MCPDaemonRecord:
f"Daemon for key={identity.instance_key} did not become ready"
)
_terminate_record_process(record)
with contextlib.suppress(FileNotFoundError):
identity.metadata_path.unlink()
_unlink_record_artifacts(record, identity.state_dir)
raise last_error or RuntimeError("Failed to start daemon")

View file

@ -212,7 +212,9 @@ class Logger:
# Console Handler
if log_to_console:
ch = logging.StreamHandler(sys.stdout)
# Logs should never share stdout with structured program output
# such as MCP stdio frames.
ch = logging.StreamHandler(sys.stderr)
ch.setLevel(resolved_level)
ch.setFormatter(console_formatter)
target_logger.addHandler(ch)
@ -229,7 +231,8 @@ class Logger:
# Record log file location
if not cls._configured:
print(f"Log file enabled: {actual_log_file}")
sys.stderr.write(f"Log file enabled: {actual_log_file}\n")
sys.stderr.flush()
cls._configured = True
@ -293,7 +296,7 @@ class Logger:
@staticmethod
def _stdout_supports_color() -> bool:
return sys.stdout.isatty() and not os.getenv("NO_COLOR")
return sys.stderr.isatty() and not os.getenv("NO_COLOR")
@classmethod
def _resolve_level(cls, level: Optional[int]) -> int:

View file

@ -0,0 +1,324 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import signal
import subprocess
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable
PROCESS_MARKERS = (
"openspace.mcp_server",
"openspace.evolution_mcp_server",
)
STATE_FILE_PREFIXES = ("main-", "evolution-")
STATE_FILE_SUFFIXES = (".json", ".lock", ".log")
@dataclass
class ManagedProcess:
pid: int
command: str
source: str
state_dir: str | None = None
record_file: str | None = None
def default_state_dirs() -> list[Path]:
env_override = os.environ.get("OPENSPACE_MCP_DAEMON_STATE_DIR", "").strip()
candidates = [
Path(env_override).expanduser().resolve() if env_override else None,
Path.home() / ".codex" / "state" / "openspace",
Path.home() / ".codex-openspace" / "state" / "openspace",
Path.home() / "Library" / "Application Support" / "openspace" / "mcp-daemons",
]
result: list[Path] = []
seen: set[str] = set()
for path in candidates:
if path is None:
continue
key = str(path)
if key in seen:
continue
seen.add(key)
result.append(path)
return result
def is_target_command(command: str) -> bool:
return any(marker in command for marker in PROCESS_MARKERS)
def pid_exists(pid: int) -> bool:
if pid <= 0:
return False
try:
os.kill(pid, 0)
return True
except OSError:
return False
def command_for_pid(pid: int) -> str:
if pid <= 0:
return ""
proc = subprocess.run(
["ps", "-o", "command=", "-p", str(pid)],
check=False,
capture_output=True,
text=True,
)
return proc.stdout.strip()
def collect_metadata_processes(state_dir: Path) -> tuple[list[ManagedProcess], list[dict[str, object]], list[str]]:
managed: list[ManagedProcess] = []
metadata_rows: list[dict[str, object]] = []
warnings: list[str] = []
if not state_dir.is_dir():
return managed, metadata_rows, warnings
for metadata_path in sorted(state_dir.glob("*.json")):
try:
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
except Exception as exc: # noqa: BLE001
warnings.append(f"failed to read {metadata_path}: {exc}")
continue
pid = int(payload.get("pid") or 0)
command = command_for_pid(pid) if pid_exists(pid) else ""
live_target = bool(command and is_target_command(command))
metadata_rows.append(
{
"state_dir": str(state_dir),
"record_file": metadata_path.name,
"pid": pid,
"port": payload.get("port"),
"workspace": payload.get("workspace"),
"server_kind": payload.get("server_kind"),
"live_target": live_target,
"command": command,
}
)
if live_target:
managed.append(
ManagedProcess(
pid=pid,
command=command,
source="metadata",
state_dir=str(state_dir),
record_file=metadata_path.name,
)
)
return managed, metadata_rows, warnings
def collect_orphan_processes(excluded_pids: Iterable[int]) -> list[ManagedProcess]:
excluded = set(excluded_pids)
proc = subprocess.run(
["ps", "-axo", "pid=,command="],
check=False,
capture_output=True,
text=True,
)
results: list[ManagedProcess] = []
for line in proc.stdout.splitlines():
stripped = line.strip()
if not stripped:
continue
try:
pid_text, command = stripped.split(None, 1)
except ValueError:
continue
pid = int(pid_text)
if pid in excluded:
continue
if not is_target_command(command):
continue
results.append(ManagedProcess(pid=pid, command=command, source="orphan-scan"))
return results
def terminate_process(pid: int, timeout_seconds: float) -> str:
if not pid_exists(pid):
return "already-exited"
os.kill(pid, signal.SIGTERM)
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
if not pid_exists(pid):
return "terminated"
time.sleep(0.1)
if pid_exists(pid):
os.kill(pid, signal.SIGKILL)
deadline = time.monotonic() + 1.0
while time.monotonic() < deadline:
if not pid_exists(pid):
return "killed"
time.sleep(0.05)
return "kill-sent"
def removable_state_files(state_dir: Path, keep_logs: bool) -> list[Path]:
if not state_dir.is_dir():
return []
removable: list[Path] = []
for path in sorted(state_dir.iterdir()):
if not path.is_file():
continue
if not path.name.startswith(STATE_FILE_PREFIXES):
continue
if not path.name.endswith(STATE_FILE_SUFFIXES):
continue
if keep_logs and path.suffix == ".log":
continue
removable.append(path)
return removable
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Clean OpenSpace MCP daemon processes and state files")
parser.add_argument(
"--state-dir",
action="append",
default=[],
help="Additional state directory to clean. Can be passed multiple times.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be terminated/removed without changing anything.",
)
parser.add_argument(
"--keep-logs",
action="store_true",
help="Keep *.log files in state dirs while removing json/lock artifacts.",
)
parser.add_argument(
"--timeout-seconds",
type=float,
default=3.0,
help="How long to wait after SIGTERM before SIGKILL.",
)
parser.add_argument(
"--json",
action="store_true",
help="Print the final report as JSON.",
)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
state_dirs = default_state_dirs()
for raw in args.state_dir:
state_dirs.append(Path(raw).expanduser().resolve())
deduped_state_dirs: list[Path] = []
seen_state_dirs: set[str] = set()
for path in state_dirs:
key = str(path)
if key in seen_state_dirs:
continue
seen_state_dirs.add(key)
deduped_state_dirs.append(path)
metadata_processes: list[ManagedProcess] = []
metadata_rows: list[dict[str, object]] = []
warnings: list[str] = []
for state_dir in deduped_state_dirs:
managed, rows, row_warnings = collect_metadata_processes(state_dir)
metadata_processes.extend(managed)
metadata_rows.extend(rows)
warnings.extend(row_warnings)
orphan_processes = collect_orphan_processes(proc.pid for proc in metadata_processes)
unique_processes: dict[int, ManagedProcess] = {}
for proc in [*metadata_processes, *orphan_processes]:
unique_processes.setdefault(proc.pid, proc)
process_actions: list[dict[str, object]] = []
for proc in sorted(unique_processes.values(), key=lambda item: item.pid):
action = "would-terminate" if args.dry_run else terminate_process(proc.pid, args.timeout_seconds)
process_actions.append(
{
**asdict(proc),
"action": action,
}
)
file_actions: list[dict[str, object]] = []
for state_dir in deduped_state_dirs:
for path in removable_state_files(state_dir, keep_logs=args.keep_logs):
action = "would-remove"
if not args.dry_run:
path.unlink(missing_ok=True)
action = "removed"
file_actions.append(
{
"state_dir": str(state_dir),
"path": str(path),
"action": action,
}
)
report = {
"state_dirs": [str(path) for path in deduped_state_dirs],
"metadata_records": metadata_rows,
"process_actions": process_actions,
"file_actions": file_actions,
"warnings": warnings,
"dry_run": args.dry_run,
}
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0
print("OpenSpace daemon cleanup")
print(f"dry run: {'yes' if args.dry_run else 'no'}")
print("state dirs:")
for path in report["state_dirs"]:
print(f"- {path}")
print("\nprocesses:")
if process_actions:
for item in process_actions:
print(
f"- pid={item['pid']} source={item['source']} action={item['action']} "
f"record={item.get('record_file') or '-'}"
)
else:
print("- none")
print("\nfiles:")
if file_actions:
for item in file_actions:
print(f"- {item['action']}: {item['path']}")
else:
print("- none")
if warnings:
print("\nwarnings:")
for warning in warnings:
print(f"- {warning}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,11 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from openspace.codex_session_scenarios import main
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,147 @@
from __future__ import annotations
import json
from pathlib import Path
from openspace.codex_session_scenarios import (
cleanup_session_artifacts,
collect_session_family,
parse_exec_output,
snapshot_daemons,
)
def _write_session(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps({"type": "session_meta", "payload": payload}, ensure_ascii=False) + "\n",
encoding="utf-8",
)
def test_parse_exec_output_extracts_thread_and_opening_status() -> None:
output = """
2026-04-13T00:00:00Z WARN unrelated noise
{"type":"thread.started","thread_id":"thread-parent"}
{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"先做一次 OpenSpace 预检,再开始当前任务。\\n\\nOpenSpace session: ready\\nOpenSpace machine: ready"}}
{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"cold-start done"}}
""".strip()
result = parse_exec_output(output)
assert result.thread_id == "thread-parent"
assert result.opening is not None
assert result.opening.session_status == "ready"
assert result.opening.machine_status == "ready"
assert result.opening.fallback_present is False
assert result.agent_messages[-1] == "cold-start done"
def test_collect_session_family_discovers_descendants(tmp_path: Path) -> None:
sessions_root = tmp_path / "sessions"
parent = sessions_root / "2026/04/13/parent.jsonl"
child = sessions_root / "2026/04/13/child.jsonl"
grandchild = sessions_root / "2026/04/13/grandchild.jsonl"
unrelated = sessions_root / "2026/04/13/unrelated.jsonl"
_write_session(parent, {"id": "parent-thread"})
_write_session(
child,
{
"id": "child-thread",
"source": {
"subagent": {
"thread_spawn": {
"parent_thread_id": "parent-thread",
}
}
},
},
)
_write_session(
grandchild,
{
"id": "grandchild-thread",
"source": {
"subagent": {
"thread_spawn": {
"parent_thread_id": "child-thread",
}
}
},
},
)
_write_session(unrelated, {"id": "other-thread"})
family = collect_session_family("parent-thread", sessions_root)
assert family.thread_ids == {"parent-thread", "child-thread", "grandchild-thread"}
assert family.session_files == {parent, child, grandchild}
def test_cleanup_session_artifacts_removes_files_and_index_rows(tmp_path: Path) -> None:
sessions_root = tmp_path / "sessions"
session_file = sessions_root / "2026/04/13/parent.jsonl"
_write_session(session_file, {"id": "parent-thread"})
session_index = tmp_path / "session_index.jsonl"
session_index.write_text(
"\n".join(
[
json.dumps({"thread_id": "parent-thread", "session_file": str(session_file)}),
json.dumps({"thread_id": "keep-thread", "session_file": "/tmp/keep.jsonl"}),
]
)
+ "\n",
encoding="utf-8",
)
cleanup_session_artifacts(
thread_ids={"parent-thread"},
session_files={session_file},
session_index_path=session_index,
)
assert not session_file.exists()
remaining = session_index.read_text(encoding="utf-8")
assert "parent-thread" not in remaining
assert "keep-thread" in remaining
def test_snapshot_daemons_filters_by_workspace(tmp_path: Path) -> None:
state_dir = tmp_path / "state"
state_dir.mkdir()
matching = state_dir / "main-match.json"
other = state_dir / "main-other.json"
matching.write_text(
json.dumps(
{
"server_kind": "main",
"instance_key": "match",
"pid": 111,
"port": 9001,
"workspace": "/tmp/workspace-a",
"ready": True,
}
),
encoding="utf-8",
)
other.write_text(
json.dumps(
{
"server_kind": "main",
"instance_key": "other",
"pid": 222,
"port": 9002,
"workspace": "/tmp/workspace-b",
"ready": True,
}
),
encoding="utf-8",
)
snapshot = snapshot_daemons(state_dir, "/tmp/workspace-a")
assert set(snapshot) == {"main"}
assert snapshot["main"]["pid"] == 111

View file

@ -0,0 +1,50 @@
from __future__ import annotations
import io
import logging
from pathlib import Path
from openspace.utils.logging import Logger
def test_log_file_enable_announcement_avoids_stdout(monkeypatch, tmp_path: Path) -> None:
stdout = io.StringIO()
stderr = io.StringIO()
monkeypatch.setattr("sys.stdout", stdout)
monkeypatch.setattr("sys.stderr", stderr)
Logger.reset_configuration()
Logger.configure(
level=logging.INFO,
log_to_console=False,
log_to_file=str(tmp_path / "unit.log"),
attach_to_root=True,
)
assert stdout.getvalue() == ""
assert "Log file enabled:" in stderr.getvalue()
Logger.reset_configuration()
def test_console_logging_avoids_stdout(monkeypatch, tmp_path: Path) -> None:
stdout = io.StringIO()
stderr = io.StringIO()
monkeypatch.setattr("sys.stdout", stdout)
monkeypatch.setattr("sys.stderr", stderr)
Logger.reset_configuration()
Logger.configure(
level=logging.INFO,
log_to_console=True,
log_to_file=str(tmp_path / "unit.log"),
attach_to_root=True,
)
Logger.get_logger("openspace.test").info("console log should stay off stdout")
assert stdout.getvalue() == ""
assert "console log should stay off stdout" in stderr.getvalue()
Logger.reset_configuration()

View file

@ -34,6 +34,11 @@ def test_stdio_entrypoint_uses_stdio_transport(module_name, monkeypatch) -> None
"_maybe_start_idle_watchdog",
lambda: watchdog_calls.append(True),
)
monkeypatch.setattr(
module,
"_install_signal_handlers",
lambda: None,
)
module.run_mcp_server()
@ -63,6 +68,11 @@ def test_sse_entrypoint_does_not_forward_sse_params(module_name, monkeypatch) ->
"_maybe_start_idle_watchdog",
lambda: watchdog_calls.append(True),
)
monkeypatch.setattr(
module,
"_install_signal_handlers",
lambda: None,
)
module.run_mcp_server()
@ -95,6 +105,11 @@ def test_streamable_http_entrypoint_uses_watchdog_for_daemon(
"_maybe_start_idle_watchdog",
lambda: watchdog_calls.append(True),
)
monkeypatch.setattr(
module,
"_install_signal_handlers",
lambda: None,
)
monkeypatch.setenv("OPENSPACE_MCP_DAEMON", "1")
module.run_mcp_server()

48
tests/test_mcp_stdio.py Normal file
View file

@ -0,0 +1,48 @@
from __future__ import annotations
import io
from pathlib import Path
from openspace import mcp_stdio
class _FakeStream(io.StringIO):
def __init__(self, *, tty: bool) -> None:
super().__init__()
self._tty = tty
def isatty(self) -> bool:
return self._tty
def _reset_capture() -> None:
handle = mcp_stdio._STDERR_CAPTURE_HANDLE
if handle is not None:
handle.close()
mcp_stdio._STDERR_CAPTURE_HANDLE = None
def test_redirects_stderr_for_non_interactive_sessions(monkeypatch, tmp_path: Path) -> None:
_reset_capture()
monkeypatch.setattr("sys.stderr", _FakeStream(tty=False))
handle = mcp_stdio.maybe_redirect_stderr_to_file(tmp_path, "stderr.log")
assert handle is not None
assert Path(handle.name) == tmp_path / "stderr.log"
assert Path(handle.name).exists()
assert handle is mcp_stdio._STDERR_CAPTURE_HANDLE
_reset_capture()
def test_keeps_stderr_for_interactive_sessions(monkeypatch, tmp_path: Path) -> None:
_reset_capture()
original = _FakeStream(tty=True)
monkeypatch.setattr("sys.stderr", original)
handle = mcp_stdio.maybe_redirect_stderr_to_file(tmp_path, "stderr.log")
assert handle is None
assert mcp_stdio._STDERR_CAPTURE_HANDLE is None
assert original is not None

View file

@ -146,3 +146,90 @@ def test_update_current_daemon_status_timestamps_after_lock_wait(monkeypatch, tm
assert updated is not None
assert updated.warmed is True
assert updated.warmed_at == 107.5
def test_update_current_daemon_status_tracks_last_used_and_active_requests(monkeypatch, tmp_path) -> None:
identity = _build_identity(tmp_path)
metadata_path = identity.metadata_path
metadata_path.parent.mkdir(parents=True, exist_ok=True)
shared_mcp_runtime._write_record(metadata_path, _build_record(identity))
monkeypatch.setenv("OPENSPACE_MCP_INSTANCE_KEY", identity.instance_key)
monkeypatch.setenv("OPENSPACE_MCP_DAEMON_STATE_DIR", identity.state_dir)
monkeypatch.setattr(shared_mcp_runtime.time, "time", lambda: 111.0)
started = shared_mcp_runtime.update_current_daemon_status(
"main",
touch=True,
active_delta=1,
)
assert started is not None
assert started.active_requests == 1
assert started.last_used_at == 111.0
monkeypatch.setattr(shared_mcp_runtime.time, "time", lambda: 114.5)
finished = shared_mcp_runtime.update_current_daemon_status(
"main",
touch=True,
active_delta=-1,
)
assert finished is not None
assert finished.active_requests == 0
assert finished.last_used_at == 114.5
def test_reap_state_dir_records_limits_live_daemons_per_kind(monkeypatch, tmp_path) -> None:
records = []
for index, last_used_at in enumerate((10.0, 20.0, 30.0), start=1):
identity = shared_mcp_runtime.MCPDaemonIdentity(
server_kind="main",
workspace=f"/tmp/workspace-{index}",
resolved_model="unit-model",
llm_kwargs_fingerprint=f"llm-{index}",
backend_scope=("shell",),
host_skill_dirs=(f"/tmp/skills-{index}",),
grounding_config_fingerprint=f"cfg-{index}",
instance_key=f"key-{index}",
state_dir=str(tmp_path),
)
record = shared_mcp_runtime.MCPDaemonRecord(
server_kind="main",
instance_key=identity.instance_key,
pid=4000 + index,
port=56000 + index,
workspace=identity.workspace,
resolved_model=identity.resolved_model,
llm_kwargs_fingerprint=identity.llm_kwargs_fingerprint,
backend_scope=list(identity.backend_scope),
host_skill_dirs=list(identity.host_skill_dirs),
grounding_config_fingerprint=identity.grounding_config_fingerprint,
started_at=1.0,
log_path=str(tmp_path / f"{identity.instance_key}.log"),
ready=True,
warmed=True,
last_used_at=last_used_at,
active_requests=0,
)
shared_mcp_runtime._write_record(identity.metadata_path, record)
records.append(record)
reaped: list[str] = []
monkeypatch.setattr(shared_mcp_runtime, "_max_daemons_per_kind", lambda: 2)
monkeypatch.setattr(shared_mcp_runtime, "_pid_exists", lambda pid: True)
monkeypatch.setattr(shared_mcp_runtime, "_pid_matches_server", lambda record: True)
monkeypatch.setattr(
shared_mcp_runtime,
"_terminate_record_process",
lambda record: reaped.append(record.instance_key),
)
shared_mcp_runtime._reap_state_dir_records(
str(tmp_path),
"main",
keep_instance_key="key-3",
)
assert reaped == ["key-1"]
assert not (tmp_path / "main-key-1.json").exists()
assert (tmp_path / "main-key-2.json").exists()
assert (tmp_path / "main-key-3.json").exists()